Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,18 @@
[package]
name = "nomifun-db"
version.workspace = true
edition.workspace = true
[dependencies]
nomifun-common.workspace = true
sqlx = { workspace = true, features = ["migrate"] }
async-trait.workspace = true
fs2.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true
[dev-dependencies]
tokio.workspace = true
tempfile = "3"
@@ -0,0 +1,975 @@
-- Migration 001: Baseline schema for nomifun-backend.
--
-- This file is the 2026-06-13 "primary-key redesign" baseline (see
-- docs/superpowers/specs/2026-06-13-primary-key-redesign-design.md). It
-- supersedes and squashes the former 001(seq baseline)/002(attachments)/
-- 003(channel_per_pet). The system has never shipped, so the baseline only
-- serves BRAND-NEW databases: final-state schema + seed data, NO backfill /
-- normalization. Any pre-baseline database is renamed `*.pre-baseline.bak`
-- and recreated by database.rs during the pre-launch window.
--
-- ID model (single id per entity, NO seq dual-track; display == primary key):
-- * Cross-device entities (id leaves this machine via remote protocol /
-- ACP transcript / external IM / cross-device coordination key) use the
-- string global id `{prefix}_{uuidv7}` minted by generate_prefixed_id:
-- messages(msg_) cron_jobs(cron_) agent_metadata(agent_builtin_/agent_)
-- providers(prov_) assistants assistant_plugins assistant_users(achu_)
-- assistant_sessions knowledge_bases(kb_) teams(team_) team_agents(slot_)
-- team_tasks(task_) attachments(att_) pets(pet_, filesystem) device_id.
-- * Local-only entities use INTEGER PRIMARY KEY AUTOINCREMENT (monotonic,
-- ordered, never reused): conversations, requirements, terminal_sessions,
-- conversation_artifacts, mcp_servers, remote_agents, webhooks, mailbox,
-- knowledge_bindings(binding_id). The user-facing conversation/requirement/
-- terminal ids render as `#N`; see
-- docs/superpowers/specs/2026-06-14-numeric-session-requirement-id-design.md.
-- * Natural keys unchanged: users.id, client_preferences.key,
-- oauth_tokens.server_url, requirement_tags.tag, tag_settings.tag,
-- assistant_pairing_codes.code, system_settings(id=1).
--
-- INVARIANT: a cross-device INTEGER id (conversation/requirement/terminal)
-- enters a remote payload only as the client-provided correlation tag (safe;
-- the real remote routing key is the peer-minted sessionKey). FK column type
-- == referenced table PK type. agent-address columns (teams.lead_agent_id,
-- mailbox.to_agent_id/from_agent_id, team_tasks.owner) are NOT foreign keys:
-- they hold a slot_id OR the 'user'/'lead' sentinels, so a FK would reject
-- valid rows.
--
-- Builtin agent seed ids use the stable slug scheme `agent_builtin_{backend}`.
-- Runtime lookup goes through find_builtin_by_backend, never id literals.
--
-- Requires PRAGMA foreign_keys = ON on every connection for the ON DELETE
-- cascades below to fire (set in database.rs connect options).
------------------------------------------------------------------------
-- Core tables
------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY NOT NULL,
username TEXT NOT NULL UNIQUE,
email TEXT UNIQUE,
password_hash TEXT NOT NULL,
avatar_path TEXT,
jwt_secret TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_login INTEGER
);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
-- Singleton settings row. Deliberately NOT seeded: the settings repository
-- treats a missing row as "defaults" (get_settings returns Option) and lazily
-- creates it on first upsert.
CREATE TABLE IF NOT EXISTS system_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
language TEXT NOT NULL DEFAULT 'en-US',
notification_enabled INTEGER NOT NULL DEFAULT 1,
cron_notification_enabled INTEGER NOT NULL DEFAULT 0,
command_queue_enabled INTEGER NOT NULL DEFAULT 0,
save_upload_to_workspace INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS client_preferences (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
-- Cross-device: provider_id is embedded as a value-object snapshot in
-- conversation.model / pet config / idmm.sidecar and travels with them.
CREATE TABLE IF NOT EXISTS providers (
id TEXT PRIMARY KEY NOT NULL, -- prov_{uuidv7}
platform TEXT NOT NULL,
name TEXT NOT NULL,
base_url TEXT NOT NULL,
api_key_encrypted TEXT NOT NULL,
models TEXT NOT NULL DEFAULT '[]',
enabled INTEGER NOT NULL DEFAULT 1,
capabilities TEXT NOT NULL DEFAULT '[]',
context_limit INTEGER,
model_protocols TEXT,
model_enabled TEXT,
model_health TEXT,
bedrock_config TEXT,
is_full_url INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_providers_platform ON providers(platform);
------------------------------------------------------------------------
-- Conversations & Messages
------------------------------------------------------------------------
-- cron_job_id: the cron job that created this conversation (was the JSON
-- key extra.cronJobId + an expression index; now a real nullable FK column).
-- This forms a ring with cron_jobs.conversation_id: writers must INSERT the
-- cron row with conversation_id=NULL first, then the conversation, then
-- backfill both (see spec §9.A).
CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
extra TEXT NOT NULL DEFAULT '{}',
model TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending', 'running', 'finished')),
source TEXT,
channel_chat_id TEXT,
pinned INTEGER NOT NULL DEFAULT 0,
pinned_at INTEGER,
cron_job_id TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (cron_job_id) REFERENCES cron_jobs(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_conversations_user_id ON conversations(user_id);
CREATE INDEX IF NOT EXISTS idx_conversations_updated_at ON conversations(updated_at);
CREATE INDEX IF NOT EXISTS idx_conversations_type ON conversations(type);
CREATE INDEX IF NOT EXISTS idx_conversations_user_updated ON conversations(user_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_conversations_source ON conversations(source);
CREATE INDEX IF NOT EXISTS idx_conversations_source_updated ON conversations(source, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_conversations_source_chat ON conversations(source, channel_chat_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_conversations_cron_job_id ON conversations(cron_job_id);
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY NOT NULL, -- msg_{uuidv7}
conversation_id INTEGER NOT NULL,
msg_id TEXT,
type TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '{}',
position TEXT CHECK(position IN ('left', 'right', 'center', 'pop')),
status TEXT CHECK(status IN ('finish', 'pending', 'error', 'work')),
hidden INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id);
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
CREATE INDEX IF NOT EXISTS idx_messages_type ON messages(type);
CREATE INDEX IF NOT EXISTS idx_messages_msg_id ON messages(msg_id);
CREATE INDEX IF NOT EXISTS idx_messages_conv_created ON messages(conversation_id, created_at);
CREATE INDEX IF NOT EXISTS idx_messages_conv_created_desc ON messages(conversation_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_messages_type_created ON messages(type, created_at DESC);
-- Local-only INTEGER surrogate id. Idempotency moved off the old composite
-- text id onto a partial unique index: skill_suggest is unique per
-- (conversation, cron_job); cron_trigger has NO unique (one row per fire).
CREATE TABLE IF NOT EXISTS conversation_artifacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL,
cron_job_id TEXT,
kind TEXT NOT NULL
CHECK(kind IN ('cron_trigger', 'skill_suggest')),
status TEXT NOT NULL DEFAULT 'active'
CHECK(status IN ('active', 'pending', 'dismissed', 'saved')),
payload TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
FOREIGN KEY (cron_job_id) REFERENCES cron_jobs(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_conversation_artifacts_conversation_id ON conversation_artifacts(conversation_id);
CREATE INDEX IF NOT EXISTS idx_conversation_artifacts_created_at ON conversation_artifacts(created_at);
CREATE INDEX IF NOT EXISTS idx_conversation_artifacts_conversation_created ON conversation_artifacts(conversation_id, created_at);
CREATE INDEX IF NOT EXISTS idx_conversation_artifacts_cron_job ON conversation_artifacts(cron_job_id);
CREATE INDEX IF NOT EXISTS idx_conversation_artifacts_kind_status ON conversation_artifacts(kind, status);
CREATE UNIQUE INDEX IF NOT EXISTS uq_conversation_artifacts_skill_suggest
ON conversation_artifacts(conversation_id, cron_job_id) WHERE kind = 'skill_suggest';
------------------------------------------------------------------------
-- ACP Sessions
------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS acp_session (
conversation_id INTEGER PRIMARY KEY,
agent_backend TEXT NOT NULL,
agent_source TEXT NOT NULL,
-- Nullable: an ACP conversation can be created before a concrete catalog
-- agent is picked (legacy clients post only `backend`, or nothing at all).
-- A non-NULL value is RESTRICT-bound to agent_metadata; NULL means
-- "no agent chosen yet" and is exempt from FK enforcement (SQLite does not
-- check FKs on NULL child columns). Replaces the old empty-string sentinel,
-- which the new RESTRICT FK would reject.
agent_id TEXT,
session_id TEXT,
session_status TEXT NOT NULL DEFAULT 'idle',
session_config TEXT NOT NULL DEFAULT '{}',
last_active_at INTEGER,
suspended_at INTEGER,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
FOREIGN KEY (agent_id) REFERENCES agent_metadata(id) ON DELETE RESTRICT
);
CREATE INDEX IF NOT EXISTS idx_acp_session_status ON acp_session(session_status);
CREATE INDEX IF NOT EXISTS idx_acp_session_suspended ON acp_session(session_status, suspended_at) WHERE session_status = 'suspended';
CREATE INDEX IF NOT EXISTS idx_acp_session_agent_id ON acp_session(agent_id);
------------------------------------------------------------------------
-- Agent Metadata
------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS agent_metadata (
id TEXT PRIMARY KEY NOT NULL,
icon TEXT,
name TEXT NOT NULL,
name_i18n TEXT,
description TEXT,
description_i18n TEXT,
backend TEXT,
agent_type TEXT NOT NULL,
agent_source TEXT NOT NULL,
agent_source_info TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
command TEXT,
args TEXT,
env TEXT,
native_skills_dirs TEXT,
behavior_policy TEXT,
yolo_id TEXT,
agent_capabilities TEXT,
auth_methods TEXT,
config_options TEXT,
available_modes TEXT,
available_models TEXT,
available_commands TEXT,
sort_order INTEGER NOT NULL DEFAULT 1000,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_agent_metadata_backend ON agent_metadata(backend);
CREATE INDEX IF NOT EXISTS idx_agent_metadata_agent_type ON agent_metadata(agent_type);
CREATE INDEX IF NOT EXISTS idx_agent_metadata_sort_order ON agent_metadata(sort_order);
-- Seed agent_metadata with builtin agents.
--
-- Values are the post-001/003/004/010/012 final state of the legacy migration
-- chain: bun package pins from 004, ACP handshake captures (agent_capabilities
-- / auth_methods) from 003, command/binary_name fixes for Qoder/Vibe/Kiro from
-- 003, and the internal agent display name "Nomi" from 012. Agents without a
-- 003 handshake capture (Claude, Codex, Gemini, OpenCode, Cursor, Hermes,
-- Snow, and the non-ACP rows) keep NULL capabilities until first spawn.
INSERT OR IGNORE INTO agent_metadata
(id, icon, name, backend, agent_type, agent_source, agent_source_info,
enabled, command, args, env, native_skills_dirs, behavior_policy, yolo_id,
agent_capabilities, auth_methods,
sort_order, created_at, updated_at)
VALUES
-- ACP builtin agents
('agent_builtin_claude', '/api/assets/logos/ai-major/claude.svg', 'Claude Code',
'claude', 'acp', 'builtin', '{"binary_name":"claude","bridge_binary":"bun"}',
1, 'bun', '["x","--bun","@agentclientprotocol/claude-agent-acp@0.33.1"]', '[]',
'[".claude/skills"]',
'{"supports_side_question":true,"self_identity_sticky":true,"session_load_via_meta_field":true,"supports_team":true}',
'bypassPermissions',
NULL, NULL,
3100,
unixepoch('now','subsec')*1000, unixepoch('now','subsec')*1000),
('agent_builtin_codex', '/api/assets/logos/tools/coding/codex.svg', 'Codex CLI',
'codex', 'acp', 'builtin', '{"binary_name":"codex","bridge_binary":"bun"}',
1, 'bun', '["x","--bun","@zed-industries/codex-acp@0.14.0"]', '[]',
'[".codex/skills"]',
'{"supports_side_question":false,"supports_team":true}',
'full-access',
NULL, NULL,
3110,
unixepoch('now','subsec')*1000, unixepoch('now','subsec')*1000),
('agent_builtin_gemini', '/api/assets/logos/ai-major/gemini.svg', 'Gemini CLI',
'gemini', 'acp', 'builtin', '{"binary_name":"gemini"}',
1, 'gemini', '["--experimental-acp"]', '[]',
'[".gemini/skills"]',
'{"supports_side_question":false,"supports_team":true}',
'yolo',
NULL, NULL,
3120,
unixepoch('now','subsec')*1000, unixepoch('now','subsec')*1000),
('agent_builtin_qwen', '/api/assets/logos/ai-china/qwen.svg', 'Qwen',
'qwen', 'acp', 'builtin', '{"binary_name":"qwen"}',
1, 'qwen', '["--acp"]', '[]',
'[".qwen/skills"]',
'{"supports_side_question":false}',
'yolo',
'{"load_session":true,"prompt_capabilities":{"image":true,"audio":true,"embedded_context":true},"session_capabilities":{"list":{},"resume":{}},"mcp_capabilities":{"sse":true,"http":true}}',
'[{"id":"openai","name":"Use OpenAI API key","description":"Requires setting the `OPENAI_API_KEY` environment variable","_meta":{"type":"terminal","args":["--auth-type=openai"]}},{"id":"qwen-oauth","name":"Qwen OAuth","description":"Qwen OAuth (free tier discontinued 2026-04-15)","_meta":{"type":"terminal","args":["--auth-type=qwen-oauth"]}}]',
3130,
unixepoch('now','subsec')*1000, unixepoch('now','subsec')*1000),
('agent_builtin_codebuddy', '/api/assets/logos/tools/coding/codebuddy.svg', 'CodeBuddy',
'codebuddy', 'acp', 'builtin', '{"binary_name":"codebuddy","bridge_binary":"bun"}',
1, 'bun', '["x","--bun","@tencent-ai/codebuddy-code@2.97.0","--acp"]', '[]',
'[".codebuddy/skills"]',
'{"supports_side_question":false,"supports_team":true}',
'bypassPermissions',
'{"prompt_capabilities":{"image":true,"embedded_context":true},"mcp_capabilities":{"http":true,"sse":true},"load_session":true,"delegate_tools_support":true}',
'[{"id":"iOA","name":"Login with iOA","description":null},{"id":"external","name":"Login with Google/Github","description":null},{"id":"internal","name":"Login with WeChat","description":null},{"id":"selfhosted","name":"Login with Enterprise Domain","description":null}]',
3130,
unixepoch('now','subsec')*1000, unixepoch('now','subsec')*1000),
('agent_builtin_droid', '/api/assets/logos/brand/droid.svg', 'Droid',
'droid', 'acp', 'builtin', '{"binary_name":"droid"}',
1, 'droid', '["exec","--output-format","acp"]', '[]',
'[".factory/skills"]',
'{"supports_side_question":false}',
'yolo',
'{"load_session":true,"session_capabilities":{"list":{},"resume":{}},"prompt_capabilities":{"image":true,"embedded_context":true},"_meta":{"terminal_output":true,"terminal-auth":true}}',
'[{"id":"device-pairing","name":"Login","description":"Authenticate with Factory using a device pairing code in your browser."},{"id":"factory-api-key","name":"Factory API Key","description":"Authenticate using a Factory API key set in the FACTORY_API_KEY environment variable."}]',
3130,
unixepoch('now','subsec')*1000, unixepoch('now','subsec')*1000),
('agent_builtin_goose', '/api/assets/logos/tools/goose.svg', 'Goose',
'goose', 'acp', 'builtin', '{"binary_name":"goose"}',
1, 'goose', '["acp"]', '[]',
'[".goose/skills"]',
'{"supports_side_question":false}',
'yolo',
'{"load_session":true,"prompt_capabilities":{"image":true,"audio":false,"embedded_context":true},"mcp_capabilities":{"http":true,"sse":false},"session_capabilities":{"list":{},"close":{}},"auth":{}}',
'[{"id":"goose-provider","name":"Configure Provider","description":"Run `goose configure` to set up your AI provider and API key"}]',
3130,
unixepoch('now','subsec')*1000, unixepoch('now','subsec')*1000),
('agent_builtin_auggie', '/api/assets/logos/brand/auggie.svg', 'Auggie',
'auggie', 'acp', 'builtin', '{"binary_name":"auggie"}',
1, 'auggie', '["--acp"]', '[]',
NULL,
'{"supports_side_question":false}',
'yolo',
'{"load_session":true,"prompt_capabilities":{"image":true},"session_capabilities":{"list":{}}}',
'[]',
3130,
unixepoch('now','subsec')*1000, unixepoch('now','subsec')*1000),
('agent_builtin_kimi', '/api/assets/logos/ai-china/kimi.svg', 'Kimi',
'kimi', 'acp', 'builtin', '{"binary_name":"kimi"}',
1, 'kimi', '["acp"]', '[]',
'[".kimi/skills"]',
'{"supports_side_question":false}',
'yolo',
'{"load_session":true,"mcp_capabilities":{"http":true,"sse":false},"prompt_capabilities":{"audio":false,"embedded_context":true,"image":true},"session_capabilities":{"list":{},"resume":{}}}',
'[{"_meta":{"terminal-auth":{"command":"kimi","args":["login"],"label":"Kimi Code Login","env":{},"type":"terminal"}},"description":"Run `kimi login` command in the terminal, then follow the instructions to finish login.","id":"login","name":"Login with Kimi account"}]',
3130,
unixepoch('now','subsec')*1000, unixepoch('now','subsec')*1000),
('agent_builtin_opencode', '/api/assets/logos/tools/coding/opencode-light.svg', 'OpenCode',
'opencode', 'acp', 'builtin', '{"binary_name":"opencode"}',
1, 'opencode', '["acp"]', '[]',
'[".opencode/skills"]',
'{"supports_side_question":false}',
'build',
NULL, NULL,
3130,
unixepoch('now','subsec')*1000, unixepoch('now','subsec')*1000),
('agent_builtin_copilot', '/api/assets/logos/tools/github.svg', 'Copilot',
'copilot', 'acp', 'builtin', '{"binary_name":"copilot"}',
1, 'copilot', '["--acp","--stdio"]', '[]',
NULL,
'{"supports_side_question":false}',
'yolo',
'{"load_session":true,"mcp_capabilities":{"http":true,"sse":true},"prompt_capabilities":{"image":true,"audio":false,"embedded_context":true},"session_capabilities":{"list":{}}}',
'[{"id":"copilot-login","name":"Log in with Copilot CLI","description":"Run `copilot login` in the terminal","_meta":{"terminal-auth":{"command":"copilot","args":["login"],"label":"Copilot Login"}}}]',
3130,
unixepoch('now','subsec')*1000, unixepoch('now','subsec')*1000),
('agent_builtin_qoder', '/api/assets/logos/tools/coding/qoder.png', 'Qoder',
'qoder', 'acp', 'builtin', '{"binary_name":"qodercli"}',
1, 'qodercli', '["--acp"]', '[]',
NULL,
'{"supports_side_question":false}',
'yolo',
'{"load_session":true,"session_capabilities":{"list":{}},"prompt_capabilities":{"image":true,"audio":true,"embedded_context":true},"mcp_capabilities":{"http":true,"sse":true}}',
'[{"id":"qodercli-login","name":"Use qodercli login","description":"Use your existing qodercli login for this agent. If needed, sign in from qodercli first."},{"type":"env_var","id":"qoder-personal-access-token","name":"Use QODER_PERSONAL_ACCESS_TOKEN","description":"Requires `QODER_PERSONAL_ACCESS_TOKEN` in the agent environment.","vars":[{"name":"QODER_PERSONAL_ACCESS_TOKEN"}]}]',
3130,
unixepoch('now','subsec')*1000, unixepoch('now','subsec')*1000),
('agent_builtin_vibe', '/api/assets/logos/ai-major/mistral.svg', 'Vibe',
'vibe', 'acp', 'builtin', '{"binary_name":"vibe-acp"}',
1, 'vibe-acp', '[]', '[]',
'[".vibe/skills"]',
'{"supports_side_question":false}',
'yolo',
'{"load_session":true,"prompt_capabilities":{"audio":false,"embedded_context":true,"image":false},"session_capabilities":{"close":{},"fork":{},"list":{}}}',
'[]',
3130,
unixepoch('now','subsec')*1000, unixepoch('now','subsec')*1000),
('agent_builtin_cursor', '/api/assets/logos/tools/coding/cursor.png', 'Cursor',
'cursor', 'acp', 'builtin', '{"binary_name":"agent"}',
1, 'agent', '["acp"]', '[]',
'[".cursor/skills"]',
'{"supports_side_question":false}',
'agent',
NULL, NULL,
3130,
unixepoch('now','subsec')*1000, unixepoch('now','subsec')*1000),
('agent_builtin_kiro', NULL, 'Kiro',
'kiro', 'acp', 'builtin', '{"binary_name":"kiro-cli"}',
1, 'kiro-cli', '["acp"]', '[]',
NULL,
'{"supports_side_question":false}',
'yolo',
'{"load_session":true,"prompt_capabilities":{"image":true,"audio":false,"embedded_context":false},"mcp_capabilities":{"http":true,"sse":false},"session_capabilities":{}}',
'[{"id":"kiro-login","name":"Kiro Login","description":"Run ''kiro-cli login'' in terminal to authenticate. See https://kiro.dev/docs/cli/authentication/"}]',
3130,
unixepoch('now','subsec')*1000, unixepoch('now','subsec')*1000),
('agent_builtin_hermes', '/api/assets/logos/brand/hermes.svg', 'Hermes',
'hermes', 'acp', 'builtin', '{"binary_name":"hermes"}',
1, 'hermes', '["acp"]', '[]',
NULL,
'{"supports_side_question":false}',
'yolo',
NULL, NULL,
3130,
unixepoch('now','subsec')*1000, unixepoch('now','subsec')*1000),
('agent_builtin_snow', '/api/assets/logos/tools/coding/snow.png', 'Snow',
'snow', 'acp', 'builtin', '{"binary_name":"snow"}',
1, 'snow', '["--acp"]', '[]',
NULL,
'{"supports_side_question":false}',
'yolo',
NULL, NULL,
3130,
unixepoch('now','subsec')*1000, unixepoch('now','subsec')*1000),
-- Non-ACP builtins
('agent_builtin_nanobot', '/api/assets/logos/tools/nanobot.svg', 'Nanobot',
NULL, 'nanobot', 'builtin', '{"binary_name":"nanobot"}',
1, 'nanobot', '["--experimental-acp"]', '[]',
NULL,
'{}',
'yolo',
NULL, NULL,
3990,
unixepoch('now','subsec')*1000, unixepoch('now','subsec')*1000),
('agent_builtin_openclaw', '/api/assets/logos/tools/openclaw.svg', 'OpenClaw',
NULL, 'openclaw-gateway', 'builtin', '{"binary_name":"openclaw"}',
1, 'openclaw', '[]', '[]',
NULL,
'{}',
'yolo',
NULL, NULL,
3900,
unixepoch('now','subsec')*1000, unixepoch('now','subsec')*1000),
-- Internal
('agent_builtin_nomi', '/api/assets/logos/brand/nomi.svg', 'Nomi',
NULL, 'nomi', 'internal', '{}',
1, NULL, '[]', '[]',
'[".nomi/skills"]',
'{"supports_team":true}',
'yolo',
NULL, NULL,
100,
unixepoch('now','subsec')*1000, unixepoch('now','subsec')*1000);
------------------------------------------------------------------------
-- Remote Agents & MCP
------------------------------------------------------------------------
-- Local-only INTEGER id; the cross-device identity is device_id (a derived
-- key), not this row id.
CREATE TABLE IF NOT EXISTS remote_agents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
protocol TEXT NOT NULL,
url TEXT NOT NULL,
auth_type TEXT NOT NULL,
auth_token TEXT,
allow_insecure INTEGER NOT NULL DEFAULT 0,
avatar TEXT,
description TEXT,
device_id TEXT,
device_public_key TEXT,
device_private_key TEXT,
device_token TEXT,
status TEXT NOT NULL DEFAULT 'unknown',
last_connected_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_remote_agents_status ON remote_agents(status);
CREATE TABLE IF NOT EXISTS mcp_servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
enabled INTEGER NOT NULL DEFAULT 0,
transport_type TEXT NOT NULL,
transport_config TEXT NOT NULL,
tools TEXT,
last_test_status TEXT NOT NULL DEFAULT 'disconnected',
last_connected INTEGER,
original_json TEXT,
builtin INTEGER NOT NULL DEFAULT 0,
deleted_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mcp_servers_name ON mcp_servers(name);
CREATE INDEX IF NOT EXISTS idx_mcp_servers_enabled ON mcp_servers(enabled);
CREATE INDEX IF NOT EXISTS idx_mcp_servers_deleted_at ON mcp_servers(deleted_at);
CREATE TABLE IF NOT EXISTS oauth_tokens (
server_url TEXT PRIMARY KEY NOT NULL,
access_token TEXT NOT NULL,
refresh_token TEXT,
token_type TEXT NOT NULL DEFAULT 'bearer',
expires_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
------------------------------------------------------------------------
-- Assistants (channel / IM-facing: cross-device TEXT ids)
------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS assistants (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
avatar TEXT,
preset_agent_type TEXT NOT NULL DEFAULT 'gemini',
enabled_skills TEXT,
custom_skill_names TEXT,
disabled_builtin_skills TEXT,
prompts TEXT,
models TEXT,
name_i18n TEXT,
description_i18n TEXT,
prompts_i18n TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_assistants_updated_at ON assistants(updated_at DESC);
-- assistant_id may reference a builtin assistant defined in JSON (not a row
-- in `assistants`), so NO foreign key: orphans are GC'd by the assistant
-- service's delete_orphans(valid_ids).
CREATE TABLE IF NOT EXISTS assistant_overrides (
assistant_id TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
preset_agent_type TEXT,
last_used_at INTEGER,
updated_at INTEGER NOT NULL
);
-- Multi-row channel plugins: one row per connected bot. `pet_id` binds the
-- bot to one pet (filesystem entity, no FK). `bot_key` is the platform-level
-- bot identity; the partial unique index guarantees one bot binds to at most
-- one pet. (Merged from former migration 003; legacy backfill dropped.)
CREATE TABLE IF NOT EXISTS assistant_plugins (
id TEXT PRIMARY KEY NOT NULL,
type TEXT NOT NULL,
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0,
config TEXT NOT NULL,
status TEXT,
last_connected INTEGER,
pet_id TEXT,
bot_key TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_assistant_plugins_type_bot_key
ON assistant_plugins(type, bot_key) WHERE bot_key IS NOT NULL;
CREATE TABLE IF NOT EXISTS assistant_users (
id TEXT PRIMARY KEY NOT NULL, -- achu_{uuidv7}
platform_user_id TEXT NOT NULL,
platform_type TEXT NOT NULL,
display_name TEXT,
authorized_at INTEGER NOT NULL,
last_active INTEGER,
session_id TEXT,
UNIQUE (platform_user_id, platform_type)
);
-- channel_id: which bot plugin owns this session (so two bots sharing a chat
-- get isolated sessions). Merged from former migration 003.
CREATE TABLE IF NOT EXISTS assistant_sessions (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
agent_type TEXT NOT NULL,
conversation_id INTEGER,
workspace TEXT,
chat_id TEXT,
channel_id TEXT,
created_at INTEGER NOT NULL,
last_activity INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES assistant_users(id) ON DELETE CASCADE,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE SET NULL,
FOREIGN KEY (channel_id) REFERENCES assistant_plugins(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_assistant_sessions_user_id ON assistant_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_assistant_sessions_user_chat ON assistant_sessions(user_id, chat_id);
CREATE INDEX IF NOT EXISTS idx_assistant_sessions_channel ON assistant_sessions(channel_id);
CREATE TABLE IF NOT EXISTS assistant_pairing_codes (
code TEXT PRIMARY KEY NOT NULL,
platform_user_id TEXT NOT NULL,
platform_type TEXT NOT NULL,
display_name TEXT,
requested_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'approved', 'rejected', 'expired'))
);
CREATE INDEX IF NOT EXISTS idx_pairing_codes_status ON assistant_pairing_codes(status);
------------------------------------------------------------------------
-- Teams (cross-device TEXT ids: team_/slot_/task_ travel via guide MCP
-- result + team wake prompts + MCP env, recorded in ACP transcripts)
------------------------------------------------------------------------
-- lead_agent_id is an agent-address (a slot_id, or the 'lead'/'user'
-- sentinel), NOT a foreign key.
CREATE TABLE IF NOT EXISTS teams (
id TEXT PRIMARY KEY NOT NULL, -- team_{uuidv7}
user_id TEXT NOT NULL DEFAULT 'system_default_user',
name TEXT NOT NULL,
workspace TEXT NOT NULL DEFAULT '',
workspace_mode TEXT NOT NULL DEFAULT 'shared',
lead_agent_id TEXT,
session_mode TEXT,
agents_version TEXT NOT NULL DEFAULT '1.0.0',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_teams_user_id ON teams(user_id);
CREATE INDEX IF NOT EXISTS idx_teams_updated_at ON teams(updated_at);
-- Columnized from the former teams.agents JSON array. slot_id stays a string
-- PK because it is transmitted in the MCP env (TEAM_AGENT_SLOT_ID) and remote
-- protocol. conversation_id FK CASCADE: create flow inserts the slot's
-- conversation before the slot row (see spec §9.A).
CREATE TABLE IF NOT EXISTS team_agents (
slot_id TEXT PRIMARY KEY NOT NULL, -- slot_{uuidv7}
team_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'teammate',
conversation_id INTEGER,
backend TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
custom_agent_id TEXT,
status TEXT,
conversation_type TEXT,
cli_path TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_team_agents_team ON team_agents(team_id, sort_order);
CREATE INDEX IF NOT EXISTS idx_team_agents_conversation ON team_agents(conversation_id);
-- to_agent_id / from_agent_id are agent-addresses (slot_id or 'user'/'lead'
-- sentinel), NOT foreign keys.
CREATE TABLE IF NOT EXISTS mailbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
team_id TEXT NOT NULL,
to_agent_id TEXT NOT NULL,
from_agent_id TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('message', 'idle_notification', 'shutdown_request')),
content TEXT NOT NULL,
summary TEXT,
files TEXT,
read INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_mailbox_team_to_read ON mailbox(team_id, to_agent_id, read);
CREATE INDEX IF NOT EXISTS idx_mailbox_team_id ON mailbox(team_id);
-- owner is an agent-address (slot_id or sentinel), NOT a foreign key.
-- blocked_by/blocks JSON arrays are columnized into team_task_deps.
CREATE TABLE IF NOT EXISTS team_tasks (
id TEXT PRIMARY KEY NOT NULL, -- task_{uuidv7}
team_id TEXT NOT NULL,
subject TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'in_progress', 'completed', 'deleted')),
owner TEXT,
metadata TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_team_tasks_team_id ON team_tasks(team_id);
-- Single-directed dependency edge (replaces the bidirectional blocked_by/
-- blocks JSON arrays). "who blocks X" = WHERE blocked_task_id=X; "what X
-- blocks" = WHERE blocker_task_id=X.
CREATE TABLE IF NOT EXISTS team_task_deps (
blocker_task_id TEXT NOT NULL,
blocked_task_id TEXT NOT NULL,
PRIMARY KEY (blocker_task_id, blocked_task_id),
CHECK (blocker_task_id <> blocked_task_id),
FOREIGN KEY (blocker_task_id) REFERENCES team_tasks(id) ON DELETE CASCADE,
FOREIGN KEY (blocked_task_id) REFERENCES team_tasks(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_team_task_deps_blocked ON team_task_deps(blocked_task_id);
------------------------------------------------------------------------
-- Cron Jobs
------------------------------------------------------------------------
-- conversation_id is now NULLABLE with a FK (was NOT NULL, no FK): a
-- new_conversation job has no target until first fire. terminal_session_id
-- FK SET NULL (terminal lazily created).
CREATE TABLE IF NOT EXISTS cron_jobs (
id TEXT PRIMARY KEY NOT NULL, -- cron_{uuidv7}
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
schedule_kind TEXT NOT NULL CHECK(schedule_kind IN ('at', 'every', 'cron')),
schedule_value TEXT NOT NULL,
schedule_tz TEXT,
schedule_description TEXT,
payload_message TEXT NOT NULL,
execution_mode TEXT NOT NULL DEFAULT 'existing'
CHECK(execution_mode IN ('existing', 'new_conversation')),
agent_config TEXT,
conversation_id INTEGER,
conversation_title TEXT,
agent_type TEXT NOT NULL,
created_by TEXT NOT NULL CHECK(created_by IN ('user', 'agent')),
skill_content TEXT,
description TEXT,
target_kind TEXT NOT NULL DEFAULT 'agent',
terminal_mode TEXT,
terminal_session_id INTEGER,
terminal_command TEXT,
terminal_args TEXT,
terminal_script TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
next_run_at INTEGER,
last_run_at INTEGER,
last_status TEXT CHECK(last_status IN ('ok', 'error', 'skipped', 'missed')),
last_error TEXT,
run_count INTEGER NOT NULL DEFAULT 0,
retry_count INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE SET NULL,
FOREIGN KEY (terminal_session_id) REFERENCES terminal_sessions(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_cron_jobs_conversation ON cron_jobs(conversation_id);
CREATE INDEX IF NOT EXISTS idx_cron_jobs_next_run ON cron_jobs(next_run_at) WHERE enabled = 1;
CREATE INDEX IF NOT EXISTS idx_cron_jobs_agent_type ON cron_jobs(agent_type);
CREATE INDEX IF NOT EXISTS idx_cron_jobs_terminal_session ON cron_jobs(terminal_session_id);
------------------------------------------------------------------------
-- Terminal sessions
------------------------------------------------------------------------
-- PTY-backed interactive sessions. Scrollback kept in-memory (not persisted).
-- autowork: JSON {enabled, tag, max_requirements} — AutoWork config, nullable.
-- idmm: JSON blob — IDMM per-terminal stall-supervision config, nullable.
CREATE TABLE IF NOT EXISTS terminal_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
cwd TEXT NOT NULL,
command TEXT NOT NULL,
args TEXT NOT NULL DEFAULT '[]',
env TEXT,
backend TEXT,
mode TEXT,
cols INTEGER NOT NULL DEFAULT 80,
rows INTEGER NOT NULL DEFAULT 24,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_status TEXT NOT NULL DEFAULT 'running'
CHECK(last_status IN ('running', 'exited', 'error')),
exit_code INTEGER,
user_id TEXT NOT NULL,
pinned INTEGER NOT NULL DEFAULT 0,
pinned_at INTEGER,
autowork TEXT,
idmm TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_terminal_sessions_user ON terminal_sessions(user_id);
------------------------------------------------------------------------
-- Requirements Platform
------------------------------------------------------------------------
-- owner_session_id records the executing session and is a dual-domain
-- address (a conv_* conversation id OR a term_* terminal id), discriminated
-- by owner_kind. No FK (single column cannot reference two tables); when a
-- conversation/terminal is deleted the service clears the matching owner
-- (clear_owner_for_session, spec §9.B). The owner token replaces the former
-- redundant claimed_by column.
CREATE TABLE IF NOT EXISTS requirements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
tag TEXT NOT NULL,
order_key TEXT NOT NULL DEFAULT '',
sort_seq TEXT NOT NULL DEFAULT '', -- normalized sortable form of order_key (NOT a display seq)
status TEXT NOT NULL DEFAULT 'pending',
priority INTEGER NOT NULL DEFAULT 0,
completion_note TEXT,
owner_session_id INTEGER, -- conversation OR terminal id; no FK
owner_kind TEXT CHECK(owner_kind IS NULL OR owner_kind IN ('conversation', 'terminal')),
claimed_at INTEGER,
lease_expires_at INTEGER,
started_at INTEGER,
completed_at INTEGER,
attempt_count INTEGER NOT NULL DEFAULT 0,
created_by TEXT NOT NULL DEFAULT 'user',
extra TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
CHECK ((owner_session_id IS NULL) = (owner_kind IS NULL))
);
CREATE INDEX IF NOT EXISTS idx_requirements_tag_status ON requirements(tag, status);
CREATE INDEX IF NOT EXISTS idx_requirements_tag_order ON requirements(tag, sort_seq);
CREATE INDEX IF NOT EXISTS idx_requirements_owner ON requirements(owner_session_id);
CREATE INDEX IF NOT EXISTS idx_requirements_status ON requirements(status);
-- AutoWork tag-level pause. paused_req_id FK SET NULL: the triggering
-- requirement may be deleted while the pause stays.
CREATE TABLE IF NOT EXISTS requirement_tags (
tag TEXT PRIMARY KEY,
paused INTEGER NOT NULL DEFAULT 0,
paused_reason TEXT,
paused_req_id INTEGER,
paused_at INTEGER,
FOREIGN KEY (paused_req_id) REFERENCES requirements(id) ON DELETE SET NULL
);
------------------------------------------------------------------------
-- Webhooks + per-tag settings (AutoWork completion notifications)
------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS webhooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
platform TEXT NOT NULL DEFAULT 'lark',
url TEXT NOT NULL,
secret TEXT,
description TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS tag_settings (
tag TEXT PRIMARY KEY,
webhook_id INTEGER,
description TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL,
FOREIGN KEY (webhook_id) REFERENCES webhooks(id) ON DELETE SET NULL
);
------------------------------------------------------------------------
-- Knowledge Base platform
------------------------------------------------------------------------
-- Cross-device: kb id is an agent-facing gateway tool argument/result, so it
-- enters the master agent's ACP transcript -> string global id.
CREATE TABLE IF NOT EXISTS knowledge_bases (
id TEXT PRIMARY KEY, -- kb_{uuidv7}
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
root_path TEXT NOT NULL,
managed INTEGER NOT NULL DEFAULT 1,
extra TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- Per-target mount binding. The former composite PK (target_kind,target_id)
-- and JSON kb_ids array are redesigned into a surrogate binding_id +
-- type-discriminated nullable target columns (CHECK exactly-one) +
-- knowledge_binding_bases junction. target_kind set is owned by the
-- nomifun-knowledge service (BINDING_KINDS = workpath/conversation/terminal/pet).
-- workpath: normalized workspace path key (not an entity, no FK)
-- conversation/terminal: real TEXT FK CASCADE (binding dies with the session)
-- pet: pet_{} filesystem entity (no FK; pet service cleans on delete)
-- writeback_mode: 'staged' confines agent writes to {kb}/_inbox/{conversation_id}/
CREATE TABLE IF NOT EXISTS knowledge_bindings (
binding_id INTEGER PRIMARY KEY AUTOINCREMENT,
target_kind TEXT NOT NULL,
target_workpath TEXT,
target_conv_id INTEGER,
target_term_id INTEGER,
target_pet_id TEXT,
enabled INTEGER NOT NULL DEFAULT 0,
writeback INTEGER NOT NULL DEFAULT 0,
writeback_mode TEXT NOT NULL DEFAULT 'staged'
CHECK(writeback_mode IN ('staged', 'direct')),
updated_at INTEGER NOT NULL,
FOREIGN KEY (target_conv_id) REFERENCES conversations(id) ON DELETE CASCADE,
FOREIGN KEY (target_term_id) REFERENCES terminal_sessions(id) ON DELETE CASCADE,
CHECK (
(target_kind = 'workpath' AND target_workpath IS NOT NULL
AND target_conv_id IS NULL AND target_term_id IS NULL AND target_pet_id IS NULL)
OR (target_kind = 'conversation' AND target_conv_id IS NOT NULL
AND target_workpath IS NULL AND target_term_id IS NULL AND target_pet_id IS NULL)
OR (target_kind = 'terminal' AND target_term_id IS NOT NULL
AND target_workpath IS NULL AND target_conv_id IS NULL AND target_pet_id IS NULL)
OR (target_kind = 'pet' AND target_pet_id IS NOT NULL
AND target_workpath IS NULL AND target_conv_id IS NULL AND target_term_id IS NULL)
)
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_kb_binding_workpath ON knowledge_bindings(target_workpath) WHERE target_workpath IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_kb_binding_conv ON knowledge_bindings(target_conv_id) WHERE target_conv_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_kb_binding_term ON knowledge_bindings(target_term_id) WHERE target_term_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_kb_binding_pet ON knowledge_bindings(target_pet_id) WHERE target_pet_id IS NOT NULL;
-- Columnized from the former knowledge_bindings.kb_ids JSON array.
CREATE TABLE IF NOT EXISTS knowledge_binding_bases (
binding_id INTEGER NOT NULL,
kb_id TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (binding_id, kb_id),
FOREIGN KEY (binding_id) REFERENCES knowledge_bindings(binding_id) ON DELETE CASCADE,
FOREIGN KEY (kb_id) REFERENCES knowledge_bases(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_kb_binding_bases_kb ON knowledge_binding_bases(kb_id);
------------------------------------------------------------------------
-- Attachments (requirement images; was migration 002)
------------------------------------------------------------------------
-- Cross-device: att id rides the requirement DTO into the master agent's ACP
-- transcript (nomi_requirement_update result) -> string global id. The former
-- generic (kind, target_id) polymorphism is collapsed to a real
-- requirement_id FK (only the requirement kind was ever used).
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY, -- att_{uuidv7}
requirement_id INTEGER NOT NULL,
file_name TEXT NOT NULL, -- original display name (deduped per requirement)
rel_path TEXT NOT NULL, -- relative to data_dir
mime TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
created_by TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (requirement_id) REFERENCES requirements(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_attachments_requirement ON attachments(requirement_id);
------------------------------------------------------------------------
-- Conversation <-> MCP server selection (was conversations.extra.selected_mcp_server_ids)
------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS conversation_mcp_servers (
conversation_id INTEGER NOT NULL,
mcp_server_id INTEGER NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (conversation_id, mcp_server_id),
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
FOREIGN KEY (mcp_server_id) REFERENCES mcp_servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_conversation_mcp_servers_mcp ON conversation_mcp_servers(mcp_server_id);
@@ -0,0 +1,20 @@
-- Migration 002: write-back "eagerness" (回写意识) for knowledge bindings.
--
-- Adds a SECOND, orthogonal write-back axis to `knowledge_bindings`. The
-- existing `writeback_mode` controls WHERE agent writes land (`staged` inbox
-- vs `direct` body); this new `writeback_eagerness` controls HOW EAGERLY the
-- agent writes at all, while write-back is enabled:
-- * 'conservative' — the historical, restrained default: only persist
-- knowledge the model judges clearly worth keeping.
-- * 'aggressive' — capture anything plausibly relevant to a mounted base
-- without much hesitation; the user prunes later.
-- Both are purely prompt-contract wording (rendered by
-- nomifun-knowledge::context); the column only persists the user's pick.
--
-- Additive on purpose: editing 001_baseline would change its checksum and
-- trip the pre-baseline rebuild path (database.rs), wiping existing dev DBs.
-- SQLite allows ADD COLUMN with a NOT NULL DEFAULT + CHECK; the default
-- satisfies the CHECK for every pre-existing row.
ALTER TABLE knowledge_bindings
ADD COLUMN writeback_eagerness TEXT NOT NULL DEFAULT 'conservative'
CHECK(writeback_eagerness IN ('conservative', 'aggressive'));
@@ -0,0 +1,77 @@
-- 002_companion_rename.sql
-- 「pet」域整体更名为「companion」(数字伙伴)。001_baseline 已冻结(sqlx 校验和)
-- 故所有列/值的前向更名集中在本迁移:新库 = 001 建 pet_* 列后由本迁移改为 companion_*
-- 既有库 = 本迁移把存量数据迁到 companion 名。代码侧标识符已全部改为 companion。
-- 1) assistant_plugins.pet_id -> companion_id(该列无索引/约束依赖,直接改名)。
ALTER TABLE assistant_plugins RENAME COLUMN pet_id TO companion_id;
-- 2) knowledge_bindingstarget_pet_id -> target_companion_id 且 kind 'pet' -> 'companion'。
-- CHECK 约束内嵌字面量 'pet' 与列名,SQLite 无法 ALTER CHECK,需整表重建。
-- knowledge_binding_bases 通过 ON DELETE CASCADE 挂在本表下:重建期先备份、后恢复,
-- binding_id 全程保留以维持外键引用;对 foreign_keys 开/关两态都正确(DELETE+INSERT 幂等)。
CREATE TEMP TABLE _kbb_backup AS SELECT * FROM knowledge_binding_bases;
CREATE TABLE knowledge_bindings_new (
binding_id INTEGER PRIMARY KEY AUTOINCREMENT,
target_kind TEXT NOT NULL,
target_workpath TEXT,
target_conv_id INTEGER,
target_term_id INTEGER,
target_companion_id TEXT,
enabled INTEGER NOT NULL DEFAULT 0,
writeback INTEGER NOT NULL DEFAULT 0,
writeback_mode TEXT NOT NULL DEFAULT 'staged'
CHECK(writeback_mode IN ('staged', 'direct')),
writeback_eagerness TEXT NOT NULL DEFAULT 'conservative'
CHECK(writeback_eagerness IN ('conservative', 'aggressive')),
updated_at INTEGER NOT NULL,
FOREIGN KEY (target_conv_id) REFERENCES conversations(id) ON DELETE CASCADE,
FOREIGN KEY (target_term_id) REFERENCES terminal_sessions(id) ON DELETE CASCADE,
CHECK (
(target_kind = 'workpath' AND target_workpath IS NOT NULL
AND target_conv_id IS NULL AND target_term_id IS NULL AND target_companion_id IS NULL)
OR (target_kind = 'conversation' AND target_conv_id IS NOT NULL
AND target_workpath IS NULL AND target_term_id IS NULL AND target_companion_id IS NULL)
OR (target_kind = 'terminal' AND target_term_id IS NOT NULL
AND target_workpath IS NULL AND target_conv_id IS NULL AND target_companion_id IS NULL)
OR (target_kind = 'companion' AND target_companion_id IS NOT NULL
AND target_workpath IS NULL AND target_conv_id IS NULL AND target_term_id IS NULL)
)
);
INSERT INTO knowledge_bindings_new
(binding_id, target_kind, target_workpath, target_conv_id, target_term_id, target_companion_id, enabled, writeback, writeback_mode, writeback_eagerness, updated_at)
SELECT binding_id,
CASE WHEN target_kind = 'pet' THEN 'companion' ELSE target_kind END,
target_workpath, target_conv_id, target_term_id, target_pet_id,
enabled, writeback, writeback_mode, writeback_eagerness, updated_at
FROM knowledge_bindings;
DROP TABLE knowledge_bindings;
ALTER TABLE knowledge_bindings_new RENAME TO knowledge_bindings;
-- 恢复子表(CASCADE 可能已清空,或 FK 关闭时仍在):清后按备份重灌,保证幂等无重复。
DELETE FROM knowledge_binding_bases;
INSERT INTO knowledge_binding_bases SELECT * FROM _kbb_backup;
DROP TABLE _kbb_backup;
CREATE UNIQUE INDEX IF NOT EXISTS uq_kb_binding_workpath ON knowledge_bindings(target_workpath) WHERE target_workpath IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_kb_binding_conv ON knowledge_bindings(target_conv_id) WHERE target_conv_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_kb_binding_term ON knowledge_bindings(target_term_id) WHERE target_term_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_kb_binding_companion ON knowledge_bindings(target_companion_id) WHERE target_companion_id IS NOT NULL;
-- 3) conversations.extrapetCompanion -> companionSession(保留 JSON 布尔型,后端 as_bool 依赖),
-- petId -> companionId。companion 会话两键恒同时存在;先迁真值行,再清理任何遗留旧键。
UPDATE conversations
SET extra = json_remove(
json_set(
json_set(extra, '$.companionId', json_extract(extra, '$.petId')),
'$.companionSession', json('true')
),
'$.petId', '$.petCompanion')
WHERE json_valid(extra) AND json_extract(extra, '$.petCompanion') = 1;
UPDATE conversations
SET extra = json_remove(extra, '$.petCompanion', '$.petId')
WHERE json_valid(extra) AND (json_extract(extra, '$.petCompanion') IS NOT NULL OR json_extract(extra, '$.petId') IS NOT NULL);
@@ -0,0 +1,50 @@
-- Migration 004: Add branding configuration table
--
-- Stores brand customization including:
-- - Logo paths (light/dark variants)
-- - Theme colors (primary, secondary, accent)
-- - Custom CSS variables
------------------------------------------------------------------------
-- Branding configuration table
------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS branding_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
-- Logo configuration
logo_light TEXT, -- Path to light logo
logo_dark TEXT, -- Path to dark logo
logo_favicon TEXT, -- Path to favicon
-- Theme colors (hex format)
primary_color TEXT NOT NULL DEFAULT '#3B82F6',
secondary_color TEXT NOT NULL DEFAULT '#64748B',
accent_color TEXT NOT NULL DEFAULT '#10B981',
-- Background colors
background_light TEXT NOT NULL DEFAULT '#FFFFFF',
background_dark TEXT NOT NULL DEFAULT '#0F172A',
surface_light TEXT NOT NULL DEFAULT '#F8FAFC',
surface_dark TEXT NOT NULL DEFAULT '#1E293B',
-- Text colors
text_primary_light TEXT NOT NULL DEFAULT '#0F172A',
text_primary_dark TEXT NOT NULL DEFAULT '#F8FAFC',
text_secondary_light TEXT NOT NULL DEFAULT '#475569',
text_secondary_dark TEXT NOT NULL DEFAULT '#94A3B8',
-- Border colors
border_light TEXT NOT NULL DEFAULT '#E2E8F0',
border_dark TEXT NOT NULL DEFAULT '#334155',
-- Active preset name (for quick reference)
active_preset TEXT NOT NULL DEFAULT 'default',
-- Custom CSS that will be injected
custom_css TEXT NOT NULL DEFAULT '',
-- Timestamps
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- Seed default branding configuration
INSERT OR IGNORE INTO branding_config (id, created_at, updated_at)
VALUES (1, unixepoch('now', 'subsec') * 1000, unixepoch('now', 'subsec') * 1000);
------------------------------------------------------------------------
-- End of migration
------------------------------------------------------------------------
@@ -0,0 +1,57 @@
-- Migration 005: Add audit logging table
--
-- Records sensitive operations for security and compliance:
-- - Authentication events (login, logout, failed attempts)
-- - Configuration changes
-- - User management operations
-- - Data access and exports
------------------------------------------------------------------------
-- Audit log table
------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- Event classification
action TEXT NOT NULL,
category TEXT NOT NULL
CHECK(category IN (
'auth',
'user_management',
'branding',
'system_config',
'data_access',
'data_export',
'permission_change',
'other'
)),
-- Actor information
user_id TEXT,
username TEXT,
ip_address TEXT,
user_agent TEXT,
-- Event details
resource_type TEXT,
resource_id TEXT,
details TEXT NOT NULL DEFAULT '{}', -- JSON with action-specific data
-- Outcome
status TEXT NOT NULL
CHECK(status IN ('success', 'failure', 'denied'))
DEFAULT 'success',
-- Timestamp (ms since epoch)
created_at INTEGER NOT NULL
);
-- Indexes for common query patterns
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action);
CREATE INDEX IF NOT EXISTS idx_audit_log_category ON audit_log(category);
CREATE INDEX IF NOT EXISTS idx_audit_log_status ON audit_log(status);
-- Composite index for filtered queries
CREATE INDEX IF NOT EXISTS idx_audit_log_category_created ON audit_log(category, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_user_created ON audit_log(user_id, created_at DESC);
------------------------------------------------------------------------
-- End of migration
------------------------------------------------------------------------
@@ -0,0 +1,98 @@
-- Migration 006: Add domain configuration table
--
-- Stores domain-specific settings and configurations:
-- - Government: departments, approval workflows
-- - Enterprise: departments, team structures
-- - Education: schools, faculties, semesters
------------------------------------------------------------------------
-- Domain configuration table
------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS domain_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
-- Domain type (mirrors system_config.domain_type)
domain_type TEXT NOT NULL DEFAULT 'enterprise'
CHECK(domain_type IN ('government', 'enterprise', 'education')),
-- Domain-specific settings (JSON)
settings TEXT NOT NULL DEFAULT '{}',
-- Government-specific
government_settings TEXT NOT NULL DEFAULT '{}',
-- Enterprise-specific
enterprise_settings TEXT NOT NULL DEFAULT '{}',
-- Education-specific
education_settings TEXT NOT NULL DEFAULT '{}',
-- Enabled features (JSON array)
enabled_features TEXT NOT NULL DEFAULT '[]',
-- Department/division list (JSON array)
departments TEXT NOT NULL DEFAULT '[]',
-- Custom domain parameters (JSON)
custom_params TEXT NOT NULL DEFAULT '{}',
-- Timestamps
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- Seed default domain configuration
INSERT OR IGNORE INTO domain_config (id, created_at, updated_at)
VALUES (1, unixepoch('now', 'subsec') * 1000, unixepoch('now', 'subsec') * 1000);
------------------------------------------------------------------------
-- Domain presets (reference data)
------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS domain_presets (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
domain_type TEXT NOT NULL
CHECK(domain_type IN ('government', 'enterprise', 'education')),
description TEXT,
-- Default settings for this preset
settings TEXT NOT NULL DEFAULT '{}',
-- Preset configurations
config TEXT NOT NULL DEFAULT '{}',
-- Sort order
sort_order INTEGER NOT NULL DEFAULT 0,
-- Timestamps
created_at INTEGER NOT NULL
);
-- Seed government presets
INSERT OR IGNORE INTO domain_presets (id, name, domain_type, description, settings, config, sort_order, created_at)
VALUES
('gov_office', '政务办公', 'government', '政务办公场景,适合政府机关日常办公',
'{"approval_workflow": true, "document_classification": true, "multi_department": true}',
'{"departments": ["办公室", "人事处", "财务处", "业务处"], "features": ["公文管理", "审批流程", "档案管理"]}',
10, unixepoch('now', 'subsec') * 1000),
('gov_service', '政务服务', 'government', '政务服务场景,适合对外服务窗口',
'{"queue_management": true, "service_rating": true, "id_verification": true}',
'{"departments": ["综合窗口", "专项服务", "投诉受理"], "features": ["排队叫号", "服务评价", "身份核验"]}',
20, unixepoch('now', 'subsec') * 1000);
-- Seed enterprise presets
INSERT OR IGNORE INTO domain_presets (id, name, domain_type, description, settings, config, sort_order, created_at)
VALUES
('ent_corporate', '企业办公', 'enterprise', '企业通用办公场景',
'{"team_structure": true, "project_tracking": true, "knowledge_base": true}',
'{"departments": ["技术部", "市场部", "运营部", "财务部"], "features": ["项目管理", "知识库", "团队协作"]}',
10, unixepoch('now', 'subsec') * 1000),
('ent_sales', '销售团队', 'enterprise', '销售团队专用场景',
'{"crm_integration": true, "lead_tracking": true, "sales_reporting": true}',
'{"departments": ["销售一部", "销售二部", "客服部"], "features": ["客户管理", "销售报表", "线索追踪"]}',
20, unixepoch('now', 'subsec') * 1000);
-- Seed education presets
INSERT OR IGNORE INTO domain_presets (id, name, domain_type, description, settings, config, sort_order, created_at)
VALUES
('edu_university', '高等院校', 'education', '高等院校教学科研场景',
'{"semester_management": true, "course_management": true, "research_support": true}',
'{"departments": ["计算机系", "数学系", "物理系", "外语系"], "features": ["课程管理", "科研助手", "论文写作"]}',
10, unixepoch('now', 'subsec') * 1000),
('edu_school', '中小学', 'education', '中小学教育场景',
'{"grade_management": true, "homework_tracking": true, "parent_communication": true}',
'{"departments": ["语文组", "数学组", "英语组", "综合组"], "features": ["作业管理", "家长通知", "成绩分析"]}',
20, unixepoch('now', 'subsec') * 1000);
------------------------------------------------------------------------
-- End of migration
------------------------------------------------------------------------
@@ -0,0 +1,29 @@
-- Align built-in ACP agent launch metadata with the current product-owned CLI names.
-- Existing databases created before this migration keep their rows, so update
-- only the known stale command/binary pairs.
UPDATE agent_metadata
SET
agent_source_info = '{"binary_name":"agent"}',
command = 'agent',
args = '["acp"]',
updated_at = unixepoch('now','subsec') * 1000
WHERE id = 'agent_builtin_cursor'
AND agent_source = 'builtin'
AND (
agent_source_info = '{"binary_name":"cursor"}'
OR command = 'cursor'
);
UPDATE agent_metadata
SET
agent_source_info = '{"binary_name":"kiro-cli"}',
command = 'kiro-cli',
args = '["acp"]',
updated_at = unixepoch('now','subsec') * 1000
WHERE id = 'agent_builtin_kiro'
AND agent_source = 'builtin'
AND (
agent_source_info = '{"binary_name":"kiro-cli-chat"}'
OR command = 'kiro-cli-chat'
);
@@ -0,0 +1,24 @@
-- IDMM 决策记录:把原本仅存于内存(100 条环形缓冲、重启即丢、前端零渲染)的
-- 介入审计落独立表,使决策"看得见、可追溯"。激进淘汰(数据不重要,只留一点):
-- 每 target 仅留最近 30 条 + 全局 TTL 48h + 全局硬上限兜底;target_id 多态
-- (会话 TEXT / 终端 INTEGER),不设双 FK,删会话/终端时由应用层级联清理。
CREATE TABLE IF NOT EXISTS idmm_interventions (
id TEXT PRIMARY KEY NOT NULL, -- idmmrec_{uuidv7}
target_kind TEXT NOT NULL, -- 'conversation' | 'terminal'
target_id TEXT NOT NULL,
watch TEXT NOT NULL, -- 'fault'(provider/agent 故障)| 'decision'(其余),由信号种类推导
at INTEGER NOT NULL, -- epoch ms
signal TEXT NOT NULL, -- stall_class: provider_error/idle/decision/open_question/scheduled
tier_used TEXT NOT NULL, -- rule | sidecar | rule_fallback
category TEXT, -- option/open_question/permission/fault
action TEXT NOT NULL, -- retry/answer_choice/send_text/confirm/wait/stop
detail TEXT, -- 选了什么/答了什么(截断 ≤2000 字符)
reason TEXT, -- 思路(模型 reason 或规则解释;非选项分支的描述串也落这里)
confidence REAL, -- 模型置信度(规则档 NULL)
bypass_model TEXT, -- provider/model(规则档 NULL)
outcome TEXT NOT NULL -- 规范枚举 applied/resolved/failed/halted/skipped(Phase1 发 applied|halted)
);
CREATE INDEX IF NOT EXISTS idx_idmm_interventions_target
ON idmm_interventions(target_kind, target_id, at DESC);
CREATE INDEX IF NOT EXISTS idx_idmm_interventions_at
ON idmm_interventions(at);
@@ -0,0 +1,22 @@
-- 009_assistant_tags.sql
-- Two-dimension tagging for assistants (audience / scenario).
-- Per-assistant tag keys live as JSON arrays on the assistants table
-- (mirrors enabled_skills). The vocabulary's user-created entries live in
-- assistant_tags; built-in seed tags ship in tags.json (no rows here).
ALTER TABLE assistants ADD COLUMN audience_tags TEXT;
ALTER TABLE assistants ADD COLUMN scenario_tags TEXT;
-- User-created tag vocabulary only. Built-in seed tags are served from the
-- embedded tags.json manifest and merged at the service layer, so they are
-- NOT rows here. `dimension` is 'audience' | 'scenario'. No FK to assistants
-- (assistants reference tags by key in their JSON arrays; deletion cleanup is
-- done by the service).
CREATE TABLE IF NOT EXISTS assistant_tags (
key TEXT PRIMARY KEY,
dimension TEXT NOT NULL CHECK (dimension IN ('audience', 'scenario')),
label TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_assistant_tags_dimension ON assistant_tags(dimension, sort_order);
@@ -0,0 +1,15 @@
-- Migration 009: external-channel write re-enable toggle for knowledge bindings.
--
-- P1/P2 hard-disable knowledge write-back for external IM channel master-agent
-- sessions (discord/slack/lark/…) by default: an unattended bot writing to a
-- shared knowledge base is a standing risk. This column lets the user opt a
-- specific binding back in. When enabled, channel writes are still forced to
-- STAGED placement (review inbox) — never direct — so the human review gate
-- remains the safety net (enforced in `resolve_write_policy`).
--
-- Additive on purpose: editing 001_baseline would change its checksum and trip
-- the pre-baseline rebuild path (database.rs), wiping existing dev DBs. SQLite
-- allows ADD COLUMN with a NOT NULL DEFAULT; the default (0 = disabled)
-- preserves the prior behavior for every pre-existing row.
ALTER TABLE knowledge_bindings
ADD COLUMN channel_write_enabled INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,20 @@
-- Migration 010: encrypted credentials for source connectors (feishu/notion/…).
--
-- Stores per-connector credentials as an opaque AES-256-GCM ciphertext blob
-- (`payload_encrypted`); the service layer holds the encryption key and the
-- JSON payload shape (e.g. `{ "app_id": ..., "app_secret": ... }`), exactly
-- like the providers table's `api_key_encrypted`. Multiple credentials of the
-- same kind are allowed (different tenants/accounts), so the key is a surrogate
-- id, not the kind.
--
-- Additive: never touches 001_baseline (checksum stability).
CREATE TABLE IF NOT EXISTS connector_credentials (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
name TEXT NOT NULL,
payload_encrypted TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_connector_credentials_kind ON connector_credentials(kind);
@@ -0,0 +1,14 @@
-- 010_skill_tags.sql
-- Per-skill tag assignments. Skills are filesystem folders (not DB rows),
-- keyed by their unique `name`. Tags are decoupled from skill files so ANY
-- source (builtin/custom/extension/external) is taggable. Built-in seed
-- assignments ship in skill-tags.json and are merged at the route layer;
-- this table holds user assignments/overrides only. JSON-array TEXT columns
-- mirror the assistants' audience_tags/scenario_tags storage. Tag keys
-- reference the shared vocabulary (assistant_tags + tags.json).
CREATE TABLE IF NOT EXISTS skill_tags (
skill_name TEXT PRIMARY KEY,
audience_tags TEXT,
scenario_tags TEXT,
updated_at INTEGER NOT NULL
);
@@ -0,0 +1,14 @@
-- 013_knowledge_tags.sql
-- User-defined tags for knowledge bases. The `tags` column on knowledge_bases
-- stores a JSON array of tag keys assigned to that base (NULL = no tags).
-- The `knowledge_tags` table holds the tag definitions (palette).
ALTER TABLE knowledge_bases ADD COLUMN tags TEXT; -- JSON array text; NULL = no tags
CREATE TABLE knowledge_tags (
key TEXT PRIMARY KEY,
label TEXT NOT NULL,
color TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
@@ -0,0 +1,3 @@
-- Per-tag notification event filter. Comma-separated subset of done/failed/needs_review.
-- Default keeps current behavior (all three fire).
ALTER TABLE tag_settings ADD COLUMN notify_events TEXT NOT NULL DEFAULT 'done,failed,needs_review';
@@ -0,0 +1,9 @@
-- Single-row table holding the SHA-256 hash of the long-lived instance API
-- token used by the Remote capability front door (`/mcp`). All-or-nothing
-- trust: one token per instance, hash-only at rest, revocable by deleting the
-- row. CHECK(id = 1) enforces the singleton invariant.
CREATE TABLE IF NOT EXISTS instance_api_token (
id INTEGER PRIMARY KEY CHECK (id = 1),
token_hash TEXT NOT NULL,
created_at INTEGER NOT NULL
);
@@ -0,0 +1,14 @@
-- Per-companion access tokens for the Remote capability front door.
-- Replaces the singleton `instance_api_token` (015): every external connection
-- binds to exactly one companion. Only the SHA-256 hash is stored; the plaintext
-- is shown once at mint time and never persisted.
CREATE TABLE IF NOT EXISTS companion_access_token (
companion_id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL,
created_at INTEGER NOT NULL
);
-- Retire the singleton instance token. Safe whether or not 015 was ever applied
-- on this DB (fresh branch DB: 015 creates it, 016 drops it; already-migrated DB:
-- 016 drops the existing table). No dead table remains.
DROP TABLE IF EXISTS instance_api_token;
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS cron_job_runs (
id TEXT PRIMARY KEY NOT NULL,
job_id TEXT NOT NULL,
executed_at_ms INTEGER NOT NULL,
status TEXT NOT NULL CHECK(status IN ('ok', 'error', 'skipped', 'missed')),
created_at_ms INTEGER NOT NULL,
FOREIGN KEY (job_id) REFERENCES cron_jobs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_cron_job_runs_job_time
ON cron_job_runs(job_id, executed_at_ms DESC, created_at_ms DESC);
@@ -0,0 +1,30 @@
-- Migration 002: Add user role support
--
-- Single-tenant architecture: all tables retain tenant_id as extension point,
-- but the first deployed system operates in single-tenant mode.
--
-- This migration adds role-based access control to the users table.
------------------------------------------------------------------------
-- Extend users table with role field
------------------------------------------------------------------------
ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'
CHECK(role IN ('admin', 'user'));
-- Create index for role-based queries
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
------------------------------------------------------------------------
-- Seed data: upgrade existing users to admin if they are the first user
------------------------------------------------------------------------
-- The first registered user (by created_at) becomes admin
-- This handles the case where users existed before this migration
UPDATE users
SET role = 'admin'
WHERE id = (SELECT id FROM users ORDER BY created_at ASC LIMIT 1);
------------------------------------------------------------------------
-- End of migration
------------------------------------------------------------------------
@@ -0,0 +1,36 @@
-- Migration 003: Add system configuration table
--
-- Stores organization-level configuration including:
-- - Organization name
-- - Domain type (government/enterprise/education)
-- - Initialization state
------------------------------------------------------------------------
-- System configuration table
------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS system_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
organization_name TEXT NOT NULL DEFAULT 'My Organization',
domain_type TEXT NOT NULL DEFAULT 'enterprise'
CHECK(domain_type IN ('government', 'enterprise', 'education')),
initialized INTEGER NOT NULL DEFAULT 0,
init_completed_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- Seed default configuration row
INSERT OR IGNORE INTO system_config (id, created_at, updated_at)
VALUES (1, unixepoch('now', 'subsec') * 1000, unixepoch('now', 'subsec') * 1000);
------------------------------------------------------------------------
-- Extend system_settings for additional preferences
------------------------------------------------------------------------
ALTER TABLE system_settings ADD COLUMN theme_mode TEXT NOT NULL DEFAULT 'light'
CHECK(theme_mode IN ('light', 'dark', 'auto'));
------------------------------------------------------------------------
-- End of migration
------------------------------------------------------------------------
@@ -0,0 +1,72 @@
-- 004_channel_scoped_pairing.sql
-- 配对/授权域从全局 (platform_user_id, platform_type) 收敛到 per-bot:
-- 两张表加 channel_id + FK→assistant_plugins(id) ON DELETE CASCADE,
-- 唯一约束改为含 channel_id。回填到对应平台"最可能在用"的那一行。
-- ── 1) assistant_users 重建(子表 assistant_sessions 需备份/恢复)──
CREATE TEMP TABLE _sessions_backup AS SELECT * FROM assistant_sessions;
CREATE TABLE assistant_users_new (
id TEXT PRIMARY KEY NOT NULL,
platform_user_id TEXT NOT NULL,
platform_type TEXT NOT NULL,
channel_id TEXT,
display_name TEXT,
authorized_at INTEGER NOT NULL,
last_active INTEGER,
session_id TEXT,
UNIQUE (platform_user_id, platform_type, channel_id),
FOREIGN KEY (channel_id) REFERENCES assistant_plugins(id) ON DELETE CASCADE
);
INSERT INTO assistant_users_new
(id, platform_user_id, platform_type, channel_id, display_name, authorized_at, last_active, session_id)
SELECT u.id, u.platform_user_id, u.platform_type,
(SELECT p.id FROM assistant_plugins p
WHERE p.type = u.platform_type
ORDER BY (p.companion_id IS NOT NULL) DESC, p.created_at ASC
LIMIT 1),
u.display_name, u.authorized_at, u.last_active, u.session_id
FROM assistant_users u;
DROP TABLE assistant_users;
ALTER TABLE assistant_users_new RENAME TO assistant_users;
DELETE FROM assistant_sessions;
INSERT INTO assistant_sessions SELECT * FROM _sessions_backup;
DROP TABLE _sessions_backup;
CREATE INDEX IF NOT EXISTS idx_assistant_sessions_user_id ON assistant_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_assistant_sessions_user_chat ON assistant_sessions(user_id, chat_id);
CREATE INDEX IF NOT EXISTS idx_assistant_sessions_channel ON assistant_sessions(channel_id);
CREATE INDEX IF NOT EXISTS idx_assistant_users_channel ON assistant_users(channel_id);
-- ── 2) assistant_pairing_codes 重建(无子表)──
CREATE TABLE assistant_pairing_codes_new (
code TEXT PRIMARY KEY NOT NULL,
platform_user_id TEXT NOT NULL,
platform_type TEXT NOT NULL,
channel_id TEXT,
display_name TEXT,
requested_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'approved', 'rejected', 'expired')),
FOREIGN KEY (channel_id) REFERENCES assistant_plugins(id) ON DELETE CASCADE
);
INSERT INTO assistant_pairing_codes_new
(code, platform_user_id, platform_type, channel_id, display_name, requested_at, expires_at, status)
SELECT c.code, c.platform_user_id, c.platform_type,
(SELECT p.id FROM assistant_plugins p
WHERE p.type = c.platform_type
ORDER BY (p.companion_id IS NOT NULL) DESC, p.created_at ASC
LIMIT 1),
c.display_name, c.requested_at, c.expires_at, c.status
FROM assistant_pairing_codes c;
DROP TABLE assistant_pairing_codes;
ALTER TABLE assistant_pairing_codes_new RENAME TO assistant_pairing_codes;
CREATE INDEX IF NOT EXISTS idx_pairing_codes_status ON assistant_pairing_codes(status);
CREATE INDEX IF NOT EXISTS idx_pairing_codes_channel ON assistant_pairing_codes(channel_id);
@@ -0,0 +1,15 @@
-- 005_terminal_scrollback.sql
-- 终端 scrollback 跨重启持久化:把原本仅存于内存(256KB 有界缓冲)的输出历史
-- 落到独立表,使应用重启后仍能回放历史显示(配合 boot 对账把幽灵 running
-- 行改成 exited,前端即出现 relaunch 入口并回放这段历史)。
--
-- 用独立表而非给 terminal_sessions 加列:list 查询天然不拉这块大数据(保持
-- 列表轻量),且 ON DELETE CASCADE 随会话删除自动清理。
-- 写入由后端去抖驱动(仅脏会话、~5s 一次 + 进程退出时),绝不每输出块写。
CREATE TABLE IF NOT EXISTS terminal_scrollback (
session_id INTEGER PRIMARY KEY NOT NULL,
data BLOB NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES terminal_sessions(id) ON DELETE CASCADE
);
@@ -0,0 +1,22 @@
-- Keyset (cursor) pagination for conversation message history.
--
-- An ever-growing single conversation — notably a desktop companion's ONE
-- session, which now also absorbs every IM-channel turn — is loaded in
-- newest-first windows via a (created_at, id) keyset cursor instead of one
-- giant fetch:
--
-- SELECT * FROM messages
-- WHERE conversation_id = ?
-- AND type NOT IN ('cron_trigger','skill_suggest')
-- AND (created_at < ? OR (created_at = ? AND id < ?))
-- ORDER BY created_at DESC, id DESC
-- LIMIT ?
--
-- The existing idx_messages_conv_created / idx_messages_conv_created_desc cover
-- (conversation_id, created_at) but NOT the `id` tiebreaker, so the cursor's
-- (created_at, id) comparison and the matching ORDER BY degrade to a sort. This
-- composite covers both, keeping deep "load older" pages index-only and stable
-- under concurrent streaming appends. `id` is msg_{uuidv7} (time-ordered), so it
-- is a sound, monotonic tiebreaker for rows sharing a created_at millisecond.
CREATE INDEX IF NOT EXISTS idx_messages_conv_created_id
ON messages (conversation_id, created_at DESC, id DESC);
@@ -0,0 +1,642 @@
use std::fs::OpenOptions;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::Duration;
use fs2::FileExt;
use sqlx::migrate::Migrator;
use sqlx::pool::PoolOptions;
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode};
use sqlx::{Sqlite, SqlitePool};
use tracing::{info, warn};
use crate::error::DbError;
/// Maximum number of connections in the pool.
const MAX_CONNECTIONS: u32 = 5;
/// SQLite busy timeout in milliseconds.
const BUSY_TIMEOUT_MS: u64 = 5000;
const STARTUP_FILE_RETRY_DELAYS: [Duration; 5] = [
Duration::from_millis(50),
Duration::from_millis(100),
Duration::from_millis(200),
Duration::from_millis(400),
Duration::from_millis(800),
];
static DB_MIGRATOR: Migrator = sqlx::migrate!();
/// Wraps a SQLite connection pool with lifecycle management.
#[derive(Clone, Debug)]
pub struct Database {
pool: SqlitePool,
}
impl Database {
/// Returns a reference to the underlying connection pool.
pub fn pool(&self) -> &SqlitePool {
&self.pool
}
/// Closes all connections in the pool.
pub async fn close(&self) {
self.pool.close().await;
}
}
/// Initialize a file-backed SQLite database.
///
/// Creates the database file and parent directories if they don't exist,
/// configures pragmas (foreign_keys, busy_timeout, journal_mode=WAL),
/// runs migrations, and ensures the system default user exists.
///
/// If initialization fails on an existing file:
/// - A database produced by the pre-baseline migration chain (the squashed
/// 001021 history) is renamed to `*.pre-baseline.bak` and rebuilt from
/// scratch (see [`rebuild_pre_baseline_database`]).
/// - Explicit corruption-like failures attempt recovery by backing up the
/// corrupted file and creating a fresh database.
/// - Everything else (other migration errors, lock contention) fails fast.
pub async fn init_database(path: &Path) -> Result<Database, DbError> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)
.map_err(|e| DbError::Init(format!("Failed to create database directory: {e}")))?;
}
match try_init_file(path).await {
Ok(db) => Ok(db),
Err(e) if path.exists() && is_pre_baseline_migration_error(&e) => {
// Pre-launch convenience; remove before release, restoring fail-fast.
rebuild_pre_baseline_database(path, e).await
}
Err(e) if path.exists() && should_attempt_recovery(&e) => {
warn!("Database initialization failed, attempting recovery: {e}");
recover_and_retry(path, e).await
}
Err(e) => Err(e),
}
}
/// Initialize an in-memory SQLite database (for testing).
///
/// Uses a single connection to ensure all queries share the same in-memory database.
/// Note: WAL journal mode is not available for in-memory databases.
pub async fn init_database_memory() -> Result<Database, DbError> {
let opts = SqliteConnectOptions::from_str("sqlite::memory:")
.map_err(|e| DbError::Init(format!("Invalid memory connection string: {e}")))?
.foreign_keys(true)
.busy_timeout(Duration::from_millis(BUSY_TIMEOUT_MS));
let pool = PoolOptions::<Sqlite>::new()
.max_connections(1)
.connect_with(opts)
.await
.map_err(DbError::Query)?;
// In-memory DBs are not shared across processes, so no advisory lock is
// needed (and there is no on-disk path we could create one against).
run_migrations(&pool).await?;
ensure_system_user(&pool).await?;
info!("In-memory database initialized");
Ok(Database { pool })
}
async fn try_init_file(path: &Path) -> Result<Database, DbError> {
// Serialize the whole file-backed startup path, not only the sqlx
// migrator. Opening a fresh SQLite file also runs connection-level PRAGMAs
// such as WAL setup, which can race before migrations start.
let lock_path = migrate_lock_path(path);
let _guard = match MigrateLockGuard::acquire(&lock_path) {
Ok(guard) => Some(guard),
Err(e) => {
// Don't fail startup if flock isn't available (e.g. on some
// network filesystems) - fall back to SQLite busy-timeout and
// retry-on-conflict behavior below.
warn!("Could not acquire database startup lock {}: {e}", lock_path.display());
None
}
};
let opts = SqliteConnectOptions::new()
.filename(path)
.create_if_missing(true)
.foreign_keys(true)
.busy_timeout(Duration::from_millis(BUSY_TIMEOUT_MS))
.journal_mode(SqliteJournalMode::Wal);
let pool = PoolOptions::<Sqlite>::new()
.max_connections(MAX_CONNECTIONS)
.connect_with(opts)
.await
.map_err(DbError::Query)?;
let setup = async {
run_migrations(&pool).await?;
ensure_system_user(&pool).await
}
.await;
if let Err(e) = setup {
// Release every file handle before bubbling up so the caller can
// rename/backup the database file (Windows refuses to rename files
// with open handles).
pool.close().await;
return Err(e);
}
info!("Database initialized at {}", path.display());
Ok(Database { pool })
}
/// Path of the cross-process advisory lock file used to serialize concurrent
/// migrators on the same database.
///
/// We put it next to the DB file so it lives on the same filesystem (avoids
/// odd flock semantics across mount points) and gets cleaned up alongside the
/// DB if a user resets their data directory.
fn migrate_lock_path(db_path: &Path) -> PathBuf {
let mut p = db_path.to_path_buf();
let new_name = match p.file_name().and_then(|s| s.to_str()) {
Some(name) => format!("{name}.migrate.lock"),
None => "nomifun.migrate.lock".to_string(),
};
p.set_file_name(new_name);
p
}
fn retry_startup_file_op<T, F>(operation: &str, path: &Path, mut op: F) -> std::io::Result<T>
where
F: FnMut() -> std::io::Result<T>,
{
for (attempt, delay) in STARTUP_FILE_RETRY_DELAYS.iter().enumerate() {
match op() {
Ok(value) => return Ok(value),
Err(e) if is_retryable_startup_file_error(&e) => {
warn!(
operation,
path = %path.display(),
attempt = attempt + 1,
retry_after_ms = delay.as_millis(),
raw_os_error = ?e.raw_os_error(),
error = %e,
"Startup file operation failed; retrying"
);
std::thread::sleep(*delay);
}
Err(e) => return Err(e),
}
}
op()
}
fn is_retryable_startup_file_error(error: &std::io::Error) -> bool {
match error.kind() {
std::io::ErrorKind::Interrupted
| std::io::ErrorKind::PermissionDenied
| std::io::ErrorKind::TimedOut
| std::io::ErrorKind::WouldBlock => true,
_ => matches!(error.raw_os_error(), Some(5 | 32 | 33)),
}
}
async fn run_migrations(pool: &SqlitePool) -> Result<(), DbError> {
// File-backed callers hold a cross-process startup lock before opening the
// SQLite pool. sqlx-sqlite's Migrate impl has no-op
// lock()/unlock() and the migrator does list_applied → apply without an
// outer transaction, so two processes opening the same DB simultaneously
// (e.g. an auto-update spawning the new version while the old one is
// still shutting down, or `nomicore doctor` racing the server) can both
// decide to apply the same version and the slower one's INSERT into
// `_sqlx_migrations` blows up with `UNIQUE constraint failed:
// _sqlx_migrations.version`. The outer startup lock also covers
// connection PRAGMAs before migration execution.
//
// Any future table-rebuild migration (CREATE new + INSERT…SELECT + DROP
// old + ALTER…RENAME) needs two pragmas:
// - foreign_keys=OFF: prevents DROP TABLE from triggering ON DELETE CASCADE
// - legacy_alter_table=ON: prevents ALTER TABLE RENAME from rewriting FK
// references in other tables (SQLite 3.26+ rewrites them by default)
// Both must be set outside a transaction (sqlx wraps each migration in
// one), so they are applied here for every migration run.
let mut conn = pool.acquire().await.map_err(DbError::Query)?;
sqlx::query("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON")
.execute(&mut *conn)
.await
.map_err(DbError::Query)?;
let result = run_migrations_with_retry(&mut conn).await;
sqlx::query("PRAGMA foreign_keys = ON; PRAGMA legacy_alter_table = OFF")
.execute(&mut *conn)
.await
.map_err(DbError::Query)?;
result
}
/// Run sqlx migrations with one retry on `_sqlx_migrations` UNIQUE conflict.
///
/// The advisory file lock above already serialises well-behaved processes,
/// but a UNIQUE conflict can still leak through when:
/// - flock() failed (network FS, sandbox restrictions) and we proceeded.
/// - Two processes that both bypassed the lock raced.
///
/// In every UNIQUE-conflict scenario the failing migration's transaction was
/// rolled back, so re-running `sqlx::migrate!().run` is safe: the second
/// pass sees the row that the winner committed, checksum matches (same
/// shipped binary), and the migration is treated as already applied.
async fn run_migrations_with_retry(conn: &mut sqlx::SqliteConnection) -> Result<(), DbError> {
match DB_MIGRATOR.run(&mut *conn).await {
Ok(()) => Ok(()),
Err(e) if is_migrations_table_unique_conflict(&e) => {
warn!("Concurrent migrator detected (UNIQUE conflict on _sqlx_migrations); retrying");
DB_MIGRATOR.run(&mut *conn).await.map_err(DbError::Migration)
}
Err(e) => Err(DbError::Migration(e)),
}
}
/// Detect the specific "another process inserted this version first" error.
///
/// sqlx wraps the SQLite error inside `MigrateError::Execute(sqlx::Error)`.
/// We match on the textual message rather than the SQLite extended error code
/// because sqlx loses the structured code by the time it bubbles up here.
fn is_migrations_table_unique_conflict(err: &sqlx::migrate::MigrateError) -> bool {
let msg = err.to_string();
msg.contains("UNIQUE constraint failed: _sqlx_migrations.version")
}
/// RAII guard that holds an exclusive file lock for the lifetime of the
/// migration run. Drop unlocks and best-effort closes the file handle.
struct MigrateLockGuard {
file: std::fs::File,
}
impl MigrateLockGuard {
fn acquire(path: &Path) -> std::io::Result<Self> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
let file = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(path)?;
// Blocking lock — fs2 has no async variant. We're inside an async
// context but startup blocks anyway and the critical section is
// bounded (single-process migration run), so this is acceptable.
FileExt::lock_exclusive(&file)?;
Ok(Self { file })
}
}
impl Drop for MigrateLockGuard {
fn drop(&mut self) {
let _ = FileExt::unlock(&self.file);
}
}
/// Ensure the system default user exists.
///
/// Uses INSERT OR IGNORE so it is safe to call on every startup.
/// The system user has an empty password hash, which signals "needs setup".
/// Username defaults to `admin` — matches the legacy web-host login flow so
/// users upgrading from pre-M6 builds keep the same login username.
async fn ensure_system_user(pool: &SqlitePool) -> Result<(), DbError> {
let now = nomifun_common::now_ms();
sqlx::query(
"INSERT OR IGNORE INTO users (id, username, password_hash, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?)",
)
.bind("system_default_user")
.bind("admin")
.bind("")
.bind(now)
.bind(now)
.execute(pool)
.await
.map_err(DbError::Query)?;
Ok(())
}
// ---------------------------------------------------------------------------
// Pre-baseline bootstrap salvage
//
// NOTE: pre-launch convenience; remove before release, restoring fail-fast.
//
// The 2026-06-12 clean-baseline refactor squashed migrations 001021 into a
// single 001_baseline.sql, resetting the migration chain. Any dev database
// created before the squash fails sqlx validation: version 1's checksum no
// longer matches, and applied versions 221 are missing from the resolved
// set. The system has not shipped and every dev database is disposable, so
// instead of making each machine delete the file by hand we rename the old
// database (plus its -wal/-shm sidecars) to `*.pre-baseline.bak` and rebuild
// an empty database from the baseline.
// ---------------------------------------------------------------------------
/// Classify migration failures caused by a database whose `_sqlx_migrations`
/// history does not line up with the shipped (squashed) migration set.
fn is_pre_baseline_migration_error(err: &DbError) -> bool {
use sqlx::migrate::MigrateError;
matches!(
err,
DbError::Migration(
MigrateError::VersionMismatch(_)
| MigrateError::VersionMissing(_)
| MigrateError::VersionTooOld(_, _)
| MigrateError::VersionTooNew(_, _)
)
)
}
/// `{file_name}.pre-baseline.bak`, with a numeric suffix when a previous
/// backup already occupies the name.
fn pre_baseline_backup_path(path: &Path) -> PathBuf {
let file_name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("nomifun-backend.db");
let base = path.with_file_name(format!("{file_name}.pre-baseline.bak"));
if !base.exists() {
return base;
}
for n in 1..10_000 {
let candidate = path.with_file_name(format!("{file_name}.pre-baseline.bak.{n}"));
if !candidate.exists() {
return candidate;
}
}
// Practically unreachable; fall back to a timestamped name.
path.with_file_name(format!(
"{file_name}.pre-baseline.bak.{}",
nomifun_common::now_ms()
))
}
/// `{file_name}{suffix}` next to `path` (SQLite sidecars append to the full
/// file name: `nomifun-backend.db-wal`, `nomifun-backend.db-shm`).
fn sibling_with_suffix(path: &Path, suffix: &str) -> PathBuf {
let mut name = path.file_name().map(|s| s.to_os_string()).unwrap_or_default();
name.push(suffix);
path.with_file_name(name)
}
async fn rebuild_pre_baseline_database(path: &Path, original_error: DbError) -> Result<Database, DbError> {
let backup = pre_baseline_backup_path(path);
info!(
db = %path.display(),
backup = %backup.display(),
original_error = %original_error,
"Database predates the squashed 001_baseline migration chain; \
renaming it aside and rebuilding an empty database \
(pre-launch behavior — dev databases are disposable)"
);
retry_startup_file_op("rename pre-baseline database", path, || {
std::fs::rename(path, &backup)
})
.map_err(|e| {
DbError::Init(format!(
"Pre-baseline rebuild failed: could not rename old database to {}: {e}. \
Original error: {original_error}",
backup.display()
))
})?;
// Move the WAL/SHM sidecars alongside the renamed database so the new
// file does not start life next to a stale journal.
for suffix in ["-wal", "-shm"] {
let src = sibling_with_suffix(path, suffix);
if !src.exists() {
continue;
}
let dst = sibling_with_suffix(&backup, suffix);
if let Err(rename_err) =
retry_startup_file_op("rename pre-baseline sidecar", &src, || std::fs::rename(&src, &dst))
{
warn!(
sidecar = %src.display(),
error = %rename_err,
"Could not rename pre-baseline sidecar; deleting it instead"
);
retry_startup_file_op("remove pre-baseline sidecar", &src, || std::fs::remove_file(&src)).map_err(
|e| {
DbError::Init(format!(
"Pre-baseline rebuild failed: could not move or delete sidecar {}: {e}. \
Original error: {original_error}",
src.display()
))
},
)?;
}
}
match try_init_file(path).await {
Ok(db) => {
info!(
backup = %backup.display(),
"Rebuilt empty database from baseline; old database preserved at backup path"
);
Ok(db)
}
Err(retry_err) => Err(DbError::Init(format!(
"Pre-baseline rebuild failed after renaming old database to {}: {retry_err}. \
Original error: {original_error}",
backup.display()
))),
}
}
async fn recover_and_retry(path: &Path, original_error: DbError) -> Result<Database, DbError> {
let backup_path = format!("{}.backup.{}", path.display(), nomifun_common::now_ms());
warn!("Backing up corrupted database to: {backup_path}");
std::fs::rename(path, &backup_path).map_err(|e| {
DbError::Init(format!(
"Recovery failed: could not backup corrupted database: {e}. \
Original error: {original_error}"
))
})?;
match try_init_file(path).await {
Ok(db) => {
warn!("Database recovered. Backup at: {backup_path}");
Ok(db)
}
Err(retry_err) => Err(DbError::Init(format!(
"Recovery failed after backup: {retry_err}. Original error: {original_error}"
))),
}
}
fn should_attempt_recovery(err: &DbError) -> bool {
match err {
DbError::Migration(_) => false,
DbError::NotFound(_) | DbError::Conflict(_) => false,
DbError::Query(_) | DbError::Init(_) => is_corruption_like_error(err),
}
}
fn is_corruption_like_error(err: &DbError) -> bool {
let message = err.to_string().to_ascii_lowercase();
[
"sqlite_corrupt",
"database disk image is malformed",
"file is not a database",
"sqlite_notadb",
"malformed database schema",
]
.iter()
.any(|needle| message.contains(needle))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recovery_skips_migration_version_mismatch() {
let err = DbError::Migration(sqlx::migrate::MigrateError::VersionMismatch(1));
assert!(
!should_attempt_recovery(&err),
"migration checksum mismatch must not trigger corruption recovery"
);
}
#[test]
fn recovery_skips_lock_contention_errors() {
let err = DbError::Init("database is locked".into());
assert!(
!should_attempt_recovery(&err),
"lock contention must not trigger recovery"
);
}
#[test]
fn recovery_allows_corruption_like_errors() {
let err = DbError::Init("database disk image is malformed".into());
assert!(
should_attempt_recovery(&err),
"corruption-like failures should trigger recovery"
);
}
#[test]
fn pre_baseline_detector_matches_version_class_errors() {
use sqlx::migrate::MigrateError;
for err in [
MigrateError::VersionMismatch(1),
MigrateError::VersionMissing(2),
MigrateError::VersionTooOld(1, 21),
MigrateError::VersionTooNew(21, 1),
] {
assert!(
is_pre_baseline_migration_error(&DbError::Migration(err)),
"version-class migration errors should trigger pre-baseline rebuild"
);
}
}
#[test]
fn pre_baseline_detector_rejects_other_errors() {
let exec = DbError::Migration(sqlx::migrate::MigrateError::Execute(sqlx::Error::Protocol(
"boom".to_string(),
)));
assert!(
!is_pre_baseline_migration_error(&exec),
"execution failures must stay fail-fast"
);
let init = DbError::Init("database is locked".into());
assert!(!is_pre_baseline_migration_error(&init));
}
#[test]
fn pre_baseline_backup_path_appends_numeric_suffix_when_taken() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("nomifun-backend.db");
let first = pre_baseline_backup_path(&db);
assert_eq!(first.file_name().unwrap(), "nomifun-backend.db.pre-baseline.bak");
std::fs::write(&first, b"taken").unwrap();
let second = pre_baseline_backup_path(&db);
assert_eq!(second.file_name().unwrap(), "nomifun-backend.db.pre-baseline.bak.1");
std::fs::write(&second, b"taken").unwrap();
let third = pre_baseline_backup_path(&db);
assert_eq!(third.file_name().unwrap(), "nomifun-backend.db.pre-baseline.bak.2");
}
#[tokio::test]
async fn migration_preserves_fk_references() {
let db = init_database_memory().await.unwrap();
let pool = db.pool();
let fk_table: String = sqlx::query_scalar(
"SELECT \"table\" FROM pragma_foreign_key_list('messages') WHERE \"from\"='conversation_id'",
)
.fetch_one(pool)
.await
.unwrap();
assert_eq!(fk_table, "conversations");
}
#[test]
fn migrations_table_unique_conflict_detected_from_message() {
// Build the same Execute(sqlx::Error) shape that surfaces when two
// processes race on `INSERT INTO _sqlx_migrations`. The detector has
// to match on the textual message because the SQLite extended code
// is not preserved on the path through MigrateError.
let inner = sqlx::Error::Protocol("UNIQUE constraint failed: _sqlx_migrations.version".to_string());
let err = sqlx::migrate::MigrateError::Execute(inner);
assert!(is_migrations_table_unique_conflict(&err));
}
#[test]
fn migrations_table_unique_conflict_ignores_other_errors() {
let other = sqlx::migrate::MigrateError::VersionMismatch(1);
assert!(!is_migrations_table_unique_conflict(&other));
let unrelated = sqlx::migrate::MigrateError::Execute(sqlx::Error::Protocol(
"UNIQUE constraint failed: users.username".to_string(),
));
assert!(!is_migrations_table_unique_conflict(&unrelated));
}
#[test]
fn migrate_lock_path_sits_next_to_db() {
let db = Path::new("/var/lib/nomifun/nomifun-backend.db");
let lock = migrate_lock_path(db);
assert_eq!(lock.parent(), db.parent());
assert_eq!(lock.file_name().unwrap(), "nomifun-backend.db.migrate.lock");
}
#[test]
fn startup_file_retry_handles_windows_transient_lock_errors() {
for code in [5, 32, 33] {
let err = std::io::Error::from_raw_os_error(code);
assert!(
is_retryable_startup_file_error(&err),
"Windows startup file error {code} should be retryable"
);
}
}
#[test]
fn startup_file_retry_rejects_non_transient_errors() {
let err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing file");
assert!(!is_retryable_startup_file_error(&err));
}
}
@@ -0,0 +1,58 @@
use nomifun_common::AppError;
/// Database-layer errors.
#[derive(Debug, thiserror::Error)]
pub enum DbError {
#[error("Database query failed: {0}")]
Query(#[from] sqlx::Error),
#[error("Migration failed: {0}")]
Migration(#[from] sqlx::migrate::MigrateError),
#[error("Record not found: {0}")]
NotFound(String),
#[error("Duplicate record: {0}")]
Conflict(String),
#[error("Database initialization failed: {0}")]
Init(String),
}
impl From<DbError> for AppError {
fn from(err: DbError) -> Self {
match err {
DbError::NotFound(msg) => AppError::NotFound(msg),
DbError::Conflict(msg) => AppError::Conflict(msg),
DbError::Query(e) => AppError::Internal(format!("Database error: {e}")),
DbError::Migration(e) => AppError::Internal(format!("Migration error: {e}")),
DbError::Init(msg) => AppError::Internal(format!("Database init error: {msg}")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn not_found_converts_to_app_not_found() {
let db_err = DbError::NotFound("user".into());
let app_err: AppError = db_err.into();
assert!(matches!(app_err, AppError::NotFound(msg) if msg == "user"));
}
#[test]
fn conflict_converts_to_app_conflict() {
let db_err = DbError::Conflict("duplicate".into());
let app_err: AppError = db_err.into();
assert!(matches!(app_err, AppError::Conflict(msg) if msg == "duplicate"));
}
#[test]
fn init_converts_to_app_internal() {
let db_err = DbError::Init("broken".into());
let app_err: AppError = db_err.into();
assert!(matches!(app_err, AppError::Internal(_)));
}
}
@@ -0,0 +1,59 @@
//! SQLite database layer: init, migrations, repository traits, and implementations.
mod database;
mod error;
pub mod models;
mod repository;
pub use database::{Database, init_database, init_database_memory};
pub use error::DbError;
pub use models::{
AgentMetadataRow, AssistantOverrideRow, AssistantRow, AssistantTagRow, AuditCategory,
AuditLogRow, AuditStatus, BrandingConfigRow, ConnectorCredentialRow, ConversationArtifactRow,
CreateAssistantParams, CreateAssistantTagParams, CreateAuditLogParams, CreateKnowledgeTagParams,
CronJobRunRow, DomainConfigRow, DomainPreset, DomainPresetCollection, DomainType,
InitSystemParams, KnowledgeBaseRow, KnowledgeBindingRow, KnowledgeTagRow,
PaginatedAuditLogs, QueryAuditLogParams, SkillTagRow, SystemConfigRow, TagSettingRow,
TerminalSessionRow, ThemePreset, UpdateAgentHandshakeParams, UpdateAssistantParams,
UpdateAssistantTagParams, UpdateBrandingParams, UpdateKnowledgeTagParams,
UpdateSystemConfigParams, UpdateDomainConfigParams, UpsertAgentMetadataParams, UpsertOverrideParams, UpsertSkillTagParams,
User, UserRole, WebhookRow,
};
pub use repository::channel::UpdatePluginStatusParams;
pub use repository::conversation::{
ConversationFilters, ConversationRowUpdate, MessageRowUpdate, MessageSearchRow, SortOrder,
};
pub use repository::cron::{CRON_RUN_HISTORY_LIMIT, UpdateCronJobParams};
pub use repository::mcp_server::{CreateMcpServerParams, UpdateMcpServerParams};
pub use repository::oauth_token::UpsertOAuthTokenParams;
pub use repository::provider::{CreateProviderParams, UpdateProviderParams};
pub use repository::remote_agent::{CreateRemoteAgentParams, UpdateRemoteAgentParams};
pub use repository::team::{UpdateTaskParams, UpdateTeamAgentParams, UpdateTeamParams};
pub use repository::{
CreateAcpSessionParams, CreateTerminalParams, GLOBAL_CAP, IAcpSessionRepository,
IAgentMetadataRepository, IAssistantOverrideRepository, IAssistantRepository,
IAssistantTagRepository, IAttachmentRepository, IAuditLogRepository, IChannelRepository,
IClientPreferenceRepository, ICompanionTokenRepository, IConnectorCredentialRepository,
IConversationRepository, ICronRepository, IIdmmInterventionRepository, IKnowledgeRepository,
IMcpServerRepository, IOAuthTokenRepository, IProviderRepository, IRemoteAgentRepository,
IRequirementRepository, ISettingsRepository, ISkillTagRepository, ISystemConfigRepository, ITagSettingRepository,
IBrandingConfigRepository, IDomainConfigRepository,
ITeamRepository, ITerminalRepository, IUserRepository, IWebhookRepository,
ListRequirementsParams, PER_TARGET_CAP, PersistedSessionState, SaveRuntimeStateParams,
SqliteAcpSessionRepository, SqliteAgentMetadataRepository, SqliteAssistantOverrideRepository,
SqliteAssistantRepository, SqliteAssistantTagRepository, SqliteAttachmentRepository,
SqliteAuditLogRepository, SqliteBrandingConfigRepository, SqliteChannelRepository,
SqliteDomainConfigRepository,
SqliteClientPreferenceRepository, SqliteCompanionTokenRepository,
SqliteConnectorCredentialRepository, SqliteConversationRepository, SqliteCronRepository,
SqliteIdmmInterventionRepository, SqliteKnowledgeRepository, SqliteMcpServerRepository,
SqliteOAuthTokenRepository, SqliteProviderRepository, SqliteRemoteAgentRepository,
SqliteRequirementRepository, SqliteSettingsRepository, SqliteSkillTagRepository, SqliteSystemConfigRepository,
SqliteTagSettingRepository, SqliteTeamRepository, SqliteTerminalRepository,
SqliteUserRepository, SqliteWebhookRepository, TTL_MS,
};
// Re-export sqlx (and its pool type) for downstream crates that run ad-hoc
// queries against the pool without declaring their own sqlx dependency
// (e.g. nomifun-app's bootstrap relocation path rewrite).
pub use sqlx;
pub use sqlx::SqlitePool;
@@ -0,0 +1,20 @@
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
/// Row mapping for the `acp_session` table.
///
/// Stores ACP agent session state for suspend/resume across app restarts.
/// Primary key is `conversation_id` (one session per conversation).
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct AcpSessionRow {
pub conversation_id: i64,
pub agent_backend: String,
pub agent_source: String,
pub agent_id: String,
pub session_id: Option<String>,
pub session_status: String,
/// JSON object: serialized session configuration.
pub session_config: String,
pub last_active_at: Option<TimestampMs>,
pub suspended_at: Option<TimestampMs>,
}
@@ -0,0 +1,99 @@
//! Row models and parameter structs for the `agent_metadata` table.
//!
//! JSON-encoded columns (`agent_source_info`, `args`, `env`,
//! `native_skills_dirs`, `behavior_policy`, plus the ACP handshake
//! snapshots) stay as opaque strings at this layer. The ai-agent crate
//! owns the schema of these payloads and decodes them on read.
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
/// Row mapping for the `agent_metadata` table.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct AgentMetadataRow {
pub id: String,
pub icon: Option<String>,
pub name: String,
pub name_i18n: Option<String>,
pub description: Option<String>,
pub description_i18n: Option<String>,
pub backend: Option<String>,
pub agent_type: String,
pub agent_source: String,
pub agent_source_info: Option<String>,
pub enabled: bool,
pub command: Option<String>,
pub args: Option<String>,
pub env: Option<String>,
pub native_skills_dirs: Option<String>,
pub behavior_policy: Option<String>,
/// Native mode id that Nomi's legacy `yolo` / `yoloNoSandbox`
/// aliases resolve to before calling `session/set_mode`. `None`
/// means the backend has no yolo equivalent and the alias should
/// pass through unchanged.
pub yolo_id: Option<String>,
pub agent_capabilities: Option<String>,
pub auth_methods: Option<String>,
pub config_options: Option<String>,
pub available_modes: Option<String>,
pub available_models: Option<String>,
pub available_commands: Option<String>,
/// Display ordering key — smaller values appear first. See the
/// `007_agent_metadata_sort_order` migration for the range scheme.
pub sort_order: i64,
pub created_at: TimestampMs,
pub updated_at: TimestampMs,
}
/// Insert / upsert parameters for the full row.
///
/// JSON fields are pre-serialized strings; the caller is responsible for
/// encoding.
#[derive(Debug, Clone)]
pub struct UpsertAgentMetadataParams<'a> {
pub id: &'a str,
pub icon: Option<&'a str>,
pub name: &'a str,
pub name_i18n: Option<&'a str>,
pub description: Option<&'a str>,
pub description_i18n: Option<&'a str>,
pub backend: Option<&'a str>,
pub agent_type: &'a str,
pub agent_source: &'a str,
pub agent_source_info: Option<&'a str>,
pub enabled: bool,
pub command: Option<&'a str>,
pub args: Option<&'a str>,
pub env: Option<&'a str>,
pub native_skills_dirs: Option<&'a str>,
pub behavior_policy: Option<&'a str>,
pub yolo_id: Option<&'a str>,
pub agent_capabilities: Option<&'a str>,
pub auth_methods: Option<&'a str>,
pub config_options: Option<&'a str>,
pub available_modes: Option<&'a str>,
pub available_models: Option<&'a str>,
pub available_commands: Option<&'a str>,
pub sort_order: i64,
}
/// Partial update applied after an ACP initialize/authenticate handshake.
///
/// Every field is `Option<Option<&str>>` so the caller can distinguish
/// "leave untouched" (outer `None`) from "clear to NULL" (inner `None`).
#[derive(Debug, Clone, Default)]
pub struct UpdateAgentHandshakeParams<'a> {
pub agent_capabilities: Option<Option<&'a str>>,
pub auth_methods: Option<Option<&'a str>>,
pub config_options: Option<Option<&'a str>>,
pub available_modes: Option<Option<&'a str>>,
pub available_models: Option<Option<&'a str>>,
pub available_commands: Option<Option<&'a str>>,
}
@@ -0,0 +1,128 @@
//! Row models and repository parameter structs for the assistants domain.
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
/// Row mapping for the `assistants` table (user-authored assistants only).
///
/// JSON-encoded columns (`enabled_skills`, `custom_skill_names`,
/// `disabled_builtin_skills`, `prompts`, `models`, `*_i18n`) stay as opaque
/// strings at this layer; the service deserializes them.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct AssistantRow {
pub id: String,
pub name: String,
pub description: Option<String>,
pub avatar: Option<String>,
pub preset_agent_type: String,
pub enabled_skills: Option<String>,
pub custom_skill_names: Option<String>,
pub disabled_builtin_skills: Option<String>,
pub prompts: Option<String>,
pub models: Option<String>,
pub name_i18n: Option<String>,
pub description_i18n: Option<String>,
pub prompts_i18n: Option<String>,
pub audience_tags: Option<String>,
pub scenario_tags: Option<String>,
pub created_at: TimestampMs,
pub updated_at: TimestampMs,
}
/// Row mapping for the `assistant_overrides` table (per-assistant user state).
///
/// `preset_agent_type` is `Some(_)` when the user has switched the main agent
/// on a built-in assistant (which cannot be mutated at its source). `None`
/// means "inherit from the built-in / user row".
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct AssistantOverrideRow {
pub assistant_id: String,
pub enabled: bool,
pub sort_order: i32,
pub last_used_at: Option<TimestampMs>,
pub preset_agent_type: Option<String>,
pub updated_at: TimestampMs,
}
/// Insert parameters for `IAssistantRepository::create` / `::upsert`.
///
/// JSON fields are pre-serialized strings so the repository layer stays
/// agnostic to how the service encodes them.
#[derive(Debug, Clone)]
pub struct CreateAssistantParams<'a> {
pub id: &'a str,
pub name: &'a str,
pub description: Option<&'a str>,
pub avatar: Option<&'a str>,
pub preset_agent_type: &'a str,
pub enabled_skills: Option<&'a str>,
pub custom_skill_names: Option<&'a str>,
pub disabled_builtin_skills: Option<&'a str>,
pub prompts: Option<&'a str>,
pub models: Option<&'a str>,
pub name_i18n: Option<&'a str>,
pub description_i18n: Option<&'a str>,
pub prompts_i18n: Option<&'a str>,
pub audience_tags: Option<&'a str>,
pub scenario_tags: Option<&'a str>,
}
/// Partial update parameters for `IAssistantRepository::update`.
///
/// Every field is `Option` — `None` keeps the current value.
#[derive(Debug, Clone, Default)]
pub struct UpdateAssistantParams<'a> {
pub name: Option<&'a str>,
pub description: Option<Option<&'a str>>,
pub avatar: Option<Option<&'a str>>,
pub preset_agent_type: Option<&'a str>,
pub enabled_skills: Option<Option<&'a str>>,
pub custom_skill_names: Option<Option<&'a str>>,
pub disabled_builtin_skills: Option<Option<&'a str>>,
pub prompts: Option<Option<&'a str>>,
pub models: Option<Option<&'a str>>,
pub name_i18n: Option<Option<&'a str>>,
pub description_i18n: Option<Option<&'a str>>,
pub prompts_i18n: Option<Option<&'a str>>,
pub audience_tags: Option<Option<&'a str>>,
pub scenario_tags: Option<Option<&'a str>>,
}
/// Upsert parameters for `IAssistantOverrideRepository::upsert`.
///
/// `preset_agent_type` uses `Option<Option<&str>>`: outer `None` keeps the
/// current value, outer `Some(inner)` writes `inner` (which itself may be
/// `None` to clear the override).
#[derive(Debug, Clone, Default)]
pub struct UpsertOverrideParams<'a> {
pub assistant_id: &'a str,
pub enabled: bool,
pub sort_order: i32,
pub last_used_at: Option<TimestampMs>,
pub preset_agent_type: Option<Option<&'a str>>,
}
/// Row mapping for the `assistant_tags` table (user-created tags only).
/// Built-in seed tags are served from the embedded `tags.json` manifest.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct AssistantTagRow {
pub key: String,
pub dimension: String,
pub label: String,
pub sort_order: i32,
pub created_at: TimestampMs,
}
#[derive(Debug, Clone)]
pub struct CreateAssistantTagParams<'a> {
pub key: &'a str,
pub dimension: &'a str,
pub label: &'a str,
pub sort_order: i32,
}
#[derive(Debug, Clone, Default)]
pub struct UpdateAssistantTagParams<'a> {
pub label: Option<&'a str>,
pub sort_order: Option<i32>,
}
@@ -0,0 +1,21 @@
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
/// Row in the `attachments` table — requirement images. The former generic
/// (kind, target_id) polymorphism is collapsed to a real requirement_id FK
/// (only the requirement kind was ever used). id stays a string `att_` because
/// it rides the requirement DTO into the master agent's ACP transcript.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct AttachmentRow {
pub id: String,
pub requirement_id: i64,
/// Original display name, deduped per requirement (`name(2).ext`).
pub file_name: String,
/// Path relative to the data dir, e.g. `attachments/{requirement_id}/{id}.png`.
/// Stored relative so desktop data-dir relocation never has to rewrite it.
pub rel_path: String,
pub mime: String,
pub size_bytes: i64,
pub created_by: Option<String>,
pub created_at: TimestampMs,
}
@@ -0,0 +1,171 @@
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
/// Audit log category
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditCategory {
Auth,
UserManagement,
Branding,
SystemConfig,
DataAccess,
DataExport,
PermissionChange,
Other,
}
impl AuditCategory {
pub fn as_str(&self) -> &'static str {
match self {
AuditCategory::Auth => "auth",
AuditCategory::UserManagement => "user_management",
AuditCategory::Branding => "branding",
AuditCategory::SystemConfig => "system_config",
AuditCategory::DataAccess => "data_access",
AuditCategory::DataExport => "data_export",
AuditCategory::PermissionChange => "permission_change",
AuditCategory::Other => "other",
}
}
pub fn from_str(s: &str) -> Self {
match s {
"auth" => AuditCategory::Auth,
"user_management" => AuditCategory::UserManagement,
"branding" => AuditCategory::Branding,
"system_config" => AuditCategory::SystemConfig,
"data_access" => AuditCategory::DataAccess,
"data_export" => AuditCategory::DataExport,
"permission_change" => AuditCategory::PermissionChange,
_ => AuditCategory::Other,
}
}
}
/// Audit log status
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AuditStatus {
Success,
Failure,
Denied,
}
impl AuditStatus {
pub fn as_str(&self) -> &'static str {
match self {
AuditStatus::Success => "success",
AuditStatus::Failure => "failure",
AuditStatus::Denied => "denied",
}
}
pub fn from_str(s: &str) -> Self {
match s {
"failure" => AuditStatus::Failure,
"denied" => AuditStatus::Denied,
_ => AuditStatus::Success,
}
}
}
/// Row mapping for the `audit_log` table.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct AuditLogRow {
pub id: i64,
pub action: String,
pub category: String,
pub user_id: Option<String>,
pub username: Option<String>,
pub ip_address: Option<String>,
pub user_agent: Option<String>,
pub resource_type: Option<String>,
pub resource_id: Option<String>,
pub details: String,
pub status: String,
pub created_at: TimestampMs,
}
impl AuditLogRow {
pub fn category_enum(&self) -> AuditCategory {
AuditCategory::from_str(&self.category)
}
pub fn status_enum(&self) -> AuditStatus {
AuditStatus::from_str(&self.status)
}
}
/// Parameters for creating an audit log entry
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateAuditLogParams {
pub action: String,
pub category: AuditCategory,
pub user_id: Option<String>,
pub username: Option<String>,
pub ip_address: Option<String>,
pub user_agent: Option<String>,
pub resource_type: Option<String>,
pub resource_id: Option<String>,
pub details: serde_json::Value,
pub status: AuditStatus,
}
/// Parameters for querying audit logs
#[derive(Debug, Clone, Deserialize, Default)]
pub struct QueryAuditLogParams {
pub page: Option<u32>,
pub page_size: Option<u32>,
pub start_date: Option<i64>,
pub end_date: Option<i64>,
pub action: Option<String>,
pub user_id: Option<String>,
pub category: Option<String>,
pub status: Option<String>,
}
impl QueryAuditLogParams {
pub fn page(&self) -> u32 {
self.page.unwrap_or(1).max(1)
}
pub fn page_size(&self) -> u32 {
self.page_size.unwrap_or(50).clamp(1, 100)
}
pub fn offset(&self) -> u32 {
(self.page() - 1) * self.page_size()
}
}
/// Paginated audit log response
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaginatedAuditLogs {
pub items: Vec<AuditLogRow>,
pub total: i64,
pub page: u32,
pub page_size: u32,
pub total_pages: u32,
}
/// Predefined audit actions
#[allow(dead_code)]
pub mod actions {
pub const LOGIN: &str = "login";
pub const LOGOUT: &str = "logout";
pub const LOGIN_FAILED: &str = "login_failed";
pub const USER_REGISTER: &str = "user_register";
pub const USER_CREATE: &str = "user_create";
pub const USER_UPDATE: &str = "user_update";
pub const USER_DELETE: &str = "user_delete";
pub const USER_ROLE_CHANGE: &str = "user_role_change";
pub const BRANDING_UPDATE: &str = "branding_update";
pub const BRANDING_LOGO_UPLOAD: &str = "branding_logo_upload";
pub const BRANDING_PRESET_APPLY: &str = "branding_preset_apply";
pub const SYSTEM_CONFIG_UPDATE: &str = "system_config_update";
pub const SYSTEM_INIT: &str = "system_init";
pub const AUDIT_LOG_EXPORT: &str = "audit_log_export";
pub const DATA_BACKUP: &str = "data_backup";
pub const DATA_RESTORE: &str = "data_restore";
}
@@ -0,0 +1,173 @@
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
/// Row mapping for the `branding_config` table.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct BrandingConfigRow {
pub id: i32,
// Logo paths
pub logo_light: Option<String>,
pub logo_dark: Option<String>,
pub logo_favicon: Option<String>,
// Theme colors
pub primary_color: String,
pub secondary_color: String,
pub accent_color: String,
// Background colors
pub background_light: String,
pub background_dark: String,
pub surface_light: String,
pub surface_dark: String,
// Text colors
pub text_primary_light: String,
pub text_primary_dark: String,
pub text_secondary_light: String,
pub text_secondary_dark: String,
// Border colors
pub border_light: String,
pub border_dark: String,
// Active preset
pub active_preset: String,
// Custom CSS
pub custom_css: String,
// Timestamps
pub created_at: TimestampMs,
pub updated_at: TimestampMs,
}
/// Parameters for updating branding configuration
#[derive(Debug, Clone, Deserialize)]
pub struct UpdateBrandingParams {
pub logo_light: Option<String>,
pub logo_dark: Option<String>,
pub logo_favicon: Option<String>,
pub primary_color: Option<String>,
pub secondary_color: Option<String>,
pub accent_color: Option<String>,
pub background_light: Option<String>,
pub background_dark: Option<String>,
pub surface_light: Option<String>,
pub surface_dark: Option<String>,
pub text_primary_light: Option<String>,
pub text_primary_dark: Option<String>,
pub text_secondary_light: Option<String>,
pub text_secondary_dark: Option<String>,
pub border_light: Option<String>,
pub border_dark: Option<String>,
pub custom_css: Option<String>,
}
/// Preset theme definition
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThemePreset {
pub id: String,
pub name: String,
pub description: String,
pub colors: PresetColors,
}
/// Simplified preset colors
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PresetColors {
pub primary_color: String,
pub secondary_color: String,
pub accent_color: String,
pub background_light: String,
pub background_dark: String,
pub surface_light: String,
pub surface_dark: String,
pub text_primary_light: String,
pub text_primary_dark: String,
pub text_secondary_light: String,
pub text_secondary_dark: String,
pub border_light: String,
pub border_dark: String,
}
impl ThemePreset {
/// Government blue preset
pub fn government_blue() -> Self {
ThemePreset {
id: "government_blue".to_string(),
name: "政务蓝".to_string(),
description: "稳重的蓝色调,适合政务场景".to_string(),
colors: PresetColors {
primary_color: "#1E40AF".to_string(),
secondary_color: "#64748B".to_string(),
accent_color: "#DC2626".to_string(),
background_light: "#F0F4F8".to_string(),
background_dark: "#0F172A".to_string(),
surface_light: "#FFFFFF".to_string(),
surface_dark: "#1E293B".to_string(),
text_primary_light: "#0F172A".to_string(),
text_primary_dark: "#F8FAFC".to_string(),
text_secondary_light: "#475569".to_string(),
text_secondary_dark: "#94A3B8".to_string(),
border_light: "#CBD5E1".to_string(),
border_dark: "#334155".to_string(),
},
}
}
/// Enterprise blue preset
pub fn enterprise_blue() -> Self {
ThemePreset {
id: "enterprise_blue".to_string(),
name: "企业蓝".to_string(),
description: "专业的蓝色调,适合企业办公".to_string(),
colors: PresetColors {
primary_color: "#3B82F6".to_string(),
secondary_color: "#64748B".to_string(),
accent_color: "#10B981".to_string(),
background_light: "#FFFFFF".to_string(),
background_dark: "#0F172A".to_string(),
surface_light: "#F8FAFC".to_string(),
surface_dark: "#1E293B".to_string(),
text_primary_light: "#0F172A".to_string(),
text_primary_dark: "#F8FAFC".to_string(),
text_secondary_light: "#475569".to_string(),
text_secondary_dark: "#94A3B8".to_string(),
border_light: "#E2E8F0".to_string(),
border_dark: "#334155".to_string(),
},
}
}
/// Academic green preset
pub fn academic_green() -> Self {
ThemePreset {
id: "academic_green".to_string(),
name: "学术绿".to_string(),
description: "清新的绿色调,适合教育科研".to_string(),
colors: PresetColors {
primary_color: "#059669".to_string(),
secondary_color: "#64748B".to_string(),
accent_color: "#F59E0B".to_string(),
background_light: "#F0FDF4".to_string(),
background_dark: "#0F172A".to_string(),
surface_light: "#FFFFFF".to_string(),
surface_dark: "#1E293B".to_string(),
text_primary_light: "#0F172A".to_string(),
text_primary_dark: "#F8FAFC".to_string(),
text_secondary_light: "#475569".to_string(),
text_secondary_dark: "#94A3B8".to_string(),
border_light: "#D1FAE5".to_string(),
border_dark: "#334155".to_string(),
},
}
}
/// Default preset (enterprise blue)
pub fn default_preset() -> Self {
Self::enterprise_blue()
}
/// Get all available presets
pub fn all_presets() -> Vec<ThemePreset> {
vec![
Self::default_preset(),
Self::government_blue(),
Self::academic_green(),
]
}
}
@@ -0,0 +1,84 @@
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
/// Row mapping for the `assistant_plugins` table.
///
/// One row per connected bot — multiple rows may share the same platform
/// `type` (legacy rows keep `id == type`). The `config` column holds an
/// encrypted JSON blob containing credentials and options.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct ChannelPluginRow {
pub id: String,
/// Platform type (telegram, lark, dingtalk, weixin, slack, discord).
#[sqlx(rename = "type")]
pub r#type: String,
pub name: String,
pub enabled: bool,
/// JSON blob: `{ credentials, config }`. Stored encrypted at rest.
pub config: String,
pub status: Option<String>,
pub last_connected: Option<TimestampMs>,
/// Companion bound to this bot. UNIQUE(type, bot_key) guarantees a bot is
/// never bound to more than one companion.
pub companion_id: Option<String>,
/// Platform-level bot identity (lark app_id, telegram bot id, ...),
/// extracted from credentials on enable/restore.
pub bot_key: Option<String>,
pub created_at: TimestampMs,
pub updated_at: TimestampMs,
}
/// Row mapping for the `assistant_users` table.
///
/// Represents an IM user authorized to chat with the assistant.
/// UNIQUE constraint on (platform_user_id, platform_type).
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct AssistantUserRow {
pub id: String,
pub platform_user_id: String,
pub platform_type: String,
/// The `assistant_plugins` row (bot) this authorization belongs to.
/// `None` only for legacy rows the 004 migration could not backfill.
pub channel_id: Option<String>,
pub display_name: Option<String>,
pub authorized_at: TimestampMs,
pub last_active: Option<TimestampMs>,
pub session_id: Option<String>,
}
/// Row mapping for the `assistant_sessions` table.
///
/// Per-chat session linking an authorized user to a conversation.
/// FK: user_id → assistant_users(id) ON DELETE CASCADE.
/// FK: conversation_id → conversations(id) ON DELETE SET NULL.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct AssistantSessionRow {
pub id: String,
pub user_id: String,
pub agent_type: String,
pub conversation_id: Option<i64>,
pub workspace: Option<String>,
pub chat_id: Option<String>,
/// The `assistant_plugins` row this session arrived through. Two bots
/// in the same chat get isolated sessions.
pub channel_id: Option<String>,
pub created_at: TimestampMs,
pub last_activity: TimestampMs,
}
/// Row mapping for the `assistant_pairing_codes` table.
///
/// 6-digit pairing code with 10-minute expiry. Status transitions:
/// pending → approved | rejected | expired.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct PairingCodeRow {
pub code: String,
pub platform_user_id: String,
pub platform_type: String,
/// The bot channel this pairing was initiated through.
pub channel_id: Option<String>,
pub display_name: Option<String>,
pub requested_at: TimestampMs,
pub expires_at: TimestampMs,
pub status: String,
}
@@ -0,0 +1,12 @@
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
/// Row mapping for the `client_preferences` table.
///
/// Generic key-value store. Values are stored as JSON-serialized TEXT.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct ClientPreference {
pub key: String,
pub value: String,
pub updated_at: TimestampMs,
}
@@ -0,0 +1,11 @@
use nomifun_common::TimestampMs;
/// One row of `companion_access_token`: a per-companion Remote front-door token,
/// stored only as its SHA-256 hash. `companion_id` is the primary key, so each
/// companion holds at most one live token (minting again rotates it).
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct CompanionApiTokenRow {
pub companion_id: String,
pub token_hash: String,
pub created_at: TimestampMs,
}
@@ -0,0 +1,20 @@
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
/// Row in `connector_credentials` — encrypted credentials for a source connector
/// (feishu / notion / …). `payload_encrypted` is an opaque AES-256-GCM ciphertext;
/// the service layer holds the key and (de)serializes the JSON payload (e.g.
/// `{ "app_id": ..., "app_secret": ... }`). Secrets never appear on the wire —
/// API responses expose only `id` / `kind` / `name`.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct ConnectorCredentialRow {
pub id: String,
/// Connector discriminator: "feishu", "notion", …
pub kind: String,
/// User-facing label.
pub name: String,
/// AES-256-GCM ciphertext of the JSON credential payload.
pub payload_encrypted: String,
pub created_at: TimestampMs,
pub updated_at: TimestampMs,
}
@@ -0,0 +1,38 @@
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
/// Row mapping for the `conversations` table.
///
/// Enum-like fields (`type`, `status`, `source`) are stored as TEXT strings.
/// The service layer converts them to/from `nomifun_common` enums
/// (`AgentType`, `ConversationStatus`, `ConversationSource`).
///
/// JSON fields (`extra`, `model`) are stored as TEXT in SQLite and
/// deserialized by the service layer.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct ConversationRow {
pub id: i64,
pub user_id: String,
pub name: String,
/// Agent type string (e.g. "gemini", "acp", "remote").
#[sqlx(rename = "type")]
pub r#type: String,
/// JSON object: type-specific extra data.
pub extra: String,
/// JSON object: `ProviderWithModel` serialized.
pub model: Option<String>,
/// One of: "pending", "running", "finished". NULL in legacy rows.
pub status: Option<String>,
/// One of: "nomifun", "telegram", "lark", "dingtalk", "weixin".
pub source: Option<String>,
/// Channel isolation ID (e.g. "user:xxx", "group:xxx").
pub channel_chat_id: Option<String>,
/// Whether this conversation is pinned (SQLite INTEGER 0/1).
pub pinned: bool,
pub pinned_at: Option<TimestampMs>,
/// The cron job that created this conversation (was `extra.cronJobId`;
/// now a real nullable FK column to `cron_jobs`).
pub cron_job_id: Option<String>,
pub created_at: TimestampMs,
pub updated_at: TimestampMs,
}
@@ -0,0 +1,15 @@
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
/// Row mapping for the `conversation_artifacts` table.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct ConversationArtifactRow {
pub id: i64,
pub conversation_id: i64,
pub cron_job_id: Option<String>,
pub kind: String,
pub status: String,
pub payload: String,
pub created_at: TimestampMs,
pub updated_at: TimestampMs,
}
@@ -0,0 +1,147 @@
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct CronJobRow {
pub id: String,
pub name: String,
pub enabled: bool,
pub schedule_kind: String,
pub schedule_value: String,
pub schedule_tz: Option<String>,
pub schedule_description: Option<String>,
pub payload_message: String,
pub execution_mode: String,
/// JSON: serialized `CronAgentConfig`.
pub agent_config: Option<String>,
/// Target conversation; NULL for a new_conversation job before first fire
/// (FK to conversations, ON DELETE SET NULL).
pub conversation_id: Option<i64>,
pub conversation_title: Option<String>,
pub agent_type: String,
pub created_by: String,
pub skill_content: Option<String>,
pub description: Option<String>,
pub created_at: TimestampMs,
pub updated_at: TimestampMs,
pub next_run_at: Option<TimestampMs>,
pub last_run_at: Option<TimestampMs>,
pub last_status: Option<String>,
pub last_error: Option<String>,
pub run_count: i64,
pub retry_count: i64,
pub max_retries: i64,
/// Execution target: `"agent"` (default) or `"terminal"`.
#[serde(default = "default_target_kind")]
pub target_kind: String,
/// Terminal placement: `"new_terminal"` | `"existing_terminal"` (NULL for agent tasks).
#[serde(default)]
pub terminal_mode: Option<String>,
/// Bound/selected terminal session id (NULL until lazily created).
#[serde(default)]
pub terminal_session_id: Option<i64>,
/// Startup program for terminal tasks, e.g. `"$SHELL"`, `"claude"`.
#[serde(default)]
pub terminal_command: Option<String>,
/// JSON array of args for the startup program.
#[serde(default)]
pub terminal_args: Option<String>,
/// Script text written to the terminal's stdin on each fire.
#[serde(default)]
pub terminal_script: Option<String>,
}
fn default_target_kind() -> String {
"agent".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cron_job_row_serialization_roundtrip() {
let row = CronJobRow {
id: "cron_abc123".into(),
name: "Daily report".into(),
enabled: true,
schedule_kind: "cron".into(),
schedule_value: "0 0 9 * * *".into(),
schedule_tz: Some("Asia/Shanghai".into()),
schedule_description: Some("Every day at 9am".into()),
payload_message: "Generate daily report".into(),
execution_mode: "new_conversation".into(),
agent_config: Some(r#"{"backend":"openai"}"#.into()),
conversation_id: Some(101),
conversation_title: Some("Reports".into()),
agent_type: "openai".into(),
created_by: "user".into(),
skill_content: Some("---\nname: test\n---\nDo something".into()),
description: Some("A test cron job".into()),
created_at: 1000,
updated_at: 2000,
next_run_at: Some(3000),
last_run_at: Some(1500),
last_status: Some("ok".into()),
last_error: None,
run_count: 5,
retry_count: 0,
max_retries: 3,
target_kind: "agent".into(),
terminal_mode: None,
terminal_session_id: None,
terminal_command: None,
terminal_args: None,
terminal_script: None,
};
let json = serde_json::to_string(&row).expect("serialize");
let restored: CronJobRow = serde_json::from_str(&json).expect("deserialize");
assert_eq!(restored.id, row.id);
assert_eq!(restored.name, row.name);
assert!(restored.enabled);
assert_eq!(restored.schedule_kind, "cron");
assert_eq!(restored.run_count, 5);
}
#[test]
fn cron_job_row_optional_fields_default_to_none() {
let row = CronJobRow {
id: "cron_min".into(),
name: "Minimal".into(),
enabled: true,
schedule_kind: "every".into(),
schedule_value: "60000".into(),
schedule_tz: None,
schedule_description: None,
payload_message: "ping".into(),
execution_mode: "existing".into(),
agent_config: None,
conversation_id: Some(1),
conversation_title: None,
agent_type: "acp".into(),
created_by: "agent".into(),
skill_content: None,
description: None,
created_at: 100,
updated_at: 100,
next_run_at: None,
last_run_at: None,
last_status: None,
last_error: None,
run_count: 0,
retry_count: 0,
max_retries: 3,
target_kind: "agent".into(),
terminal_mode: None,
terminal_session_id: None,
terminal_command: None,
terminal_args: None,
terminal_script: None,
};
assert!(row.schedule_tz.is_none());
assert!(row.agent_config.is_none());
assert!(row.skill_content.is_none());
assert!(row.next_run_at.is_none());
assert!(row.last_status.is_none());
}
}
@@ -0,0 +1,11 @@
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, PartialEq, Eq)]
pub struct CronJobRunRow {
pub id: String,
pub job_id: String,
pub executed_at_ms: TimestampMs,
pub status: String,
pub created_at_ms: TimestampMs,
}
@@ -0,0 +1,219 @@
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
/// Row mapping for the `domain_config` table.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct DomainConfigRow {
pub id: i32,
pub domain_type: String,
pub settings: String,
pub government_settings: String,
pub enterprise_settings: String,
pub education_settings: String,
pub enabled_features: String,
pub departments: String,
pub custom_params: String,
pub created_at: TimestampMs,
pub updated_at: TimestampMs,
}
/// Row mapping for the `domain_presets` table.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct DomainPresetRow {
pub id: String,
pub name: String,
pub domain_type: String,
pub description: Option<String>,
pub settings: String,
pub config: String,
pub sort_order: i32,
pub created_at: TimestampMs,
}
/// Parameters for updating domain configuration
#[derive(Debug, Clone, Deserialize)]
pub struct UpdateDomainConfigParams {
pub settings: Option<String>,
pub government_settings: Option<String>,
pub enterprise_settings: Option<String>,
pub education_settings: Option<String>,
pub enabled_features: Option<String>,
pub departments: Option<String>,
pub custom_params: Option<String>,
}
/// Domain preset for frontend
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DomainPreset {
pub id: String,
pub name: String,
pub domain_type: String,
pub description: String,
pub features: Vec<String>,
pub departments: Vec<String>,
pub settings: serde_json::Value,
pub config: serde_json::Value,
pub sort_order: i32,
pub created_at: TimestampMs,
}
impl From<DomainPresetRow> for DomainPreset {
fn from(row: DomainPresetRow) -> Self {
let config_value: serde_json::Value =
serde_json::from_str(&row.config).unwrap_or(serde_json::Value::Null);
let features: Vec<String> = config_value
.get("features")
.and_then(|f| f.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let departments: Vec<String> = config_value
.get("departments")
.and_then(|d| d.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let settings: serde_json::Value =
serde_json::from_str(&row.settings).unwrap_or(serde_json::Value::Object(Default::default()));
DomainPreset {
id: row.id,
name: row.name,
domain_type: row.domain_type,
description: row.description.unwrap_or_default(),
features,
departments,
settings,
config: config_value,
sort_order: row.sort_order,
created_at: row.created_at,
}
}
}
/// Preset collection for a domain type
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DomainPresetCollection {
pub domain_type: String,
pub presets: Vec<DomainPreset>,
}
impl DomainPresetCollection {
/// Get presets by domain type
pub fn government() -> Self {
DomainPresetCollection {
domain_type: "government".to_string(),
presets: vec![
DomainPreset {
id: "gov_office".to_string(),
name: "政务办公".to_string(),
domain_type: "government".to_string(),
description: "政务办公场景,适合政府机关日常办公".to_string(),
features: vec![
"公文管理".to_string(),
"审批流程".to_string(),
"档案管理".to_string(),
"多部门协作".to_string(),
],
departments: vec![
"办公室".to_string(),
"人事处".to_string(),
"财务处".to_string(),
"业务处".to_string(),
],
settings: serde_json::json!({
"approval_workflow": true,
"document_classification": true,
"multi_department": true
}),
config: serde_json::json!({
"features": ["公文管理", "审批流程", "档案管理", "多部门协作"],
"departments": ["办公室", "人事处", "财务处", "业务处"]
}),
sort_order: 1,
created_at: 0,
},
],
}
}
pub fn enterprise() -> Self {
DomainPresetCollection {
domain_type: "enterprise".to_string(),
presets: vec![
DomainPreset {
id: "ent_corporate".to_string(),
name: "企业办公".to_string(),
domain_type: "enterprise".to_string(),
description: "企业通用办公场景".to_string(),
features: vec![
"项目管理".to_string(),
"知识库".to_string(),
"团队协作".to_string(),
],
departments: vec![
"技术部".to_string(),
"市场部".to_string(),
"运营部".to_string(),
"财务部".to_string(),
],
settings: serde_json::json!({
"team_structure": true,
"project_tracking": true,
"knowledge_base": true
}),
config: serde_json::json!({
"features": ["项目管理", "知识库", "团队协作"],
"departments": ["技术部", "市场部", "运营部", "财务部"]
}),
sort_order: 1,
created_at: 0,
},
],
}
}
pub fn education() -> Self {
DomainPresetCollection {
domain_type: "education".to_string(),
presets: vec![
DomainPreset {
id: "edu_university".to_string(),
name: "高等院校".to_string(),
domain_type: "education".to_string(),
description: "高等院校教学科研场景".to_string(),
features: vec![
"课程管理".to_string(),
"科研助手".to_string(),
"论文写作".to_string(),
],
departments: vec![
"计算机系".to_string(),
"数学系".to_string(),
"物理系".to_string(),
"外语系".to_string(),
],
settings: serde_json::json!({
"semester_management": true,
"course_management": true,
"research_support": true
}),
config: serde_json::json!({
"features": ["课程管理", "科研助手", "论文写作"],
"departments": ["计算机系", "数学系", "物理系", "外语系"]
}),
sort_order: 1,
created_at: 0,
},
],
}
}
}
@@ -0,0 +1,23 @@
use serde::{Deserialize, Serialize};
/// Row in `idmm_interventions` — one persisted IDMM decision (the "思路"/audit
/// trail). Aggressively evicted: per-target cap + global TTL; cascades away on
/// session delete. `target_id` is polymorphic (conversation TEXT / terminal
/// INTEGER stored as string) so there is no FK — app-level cascade handles it.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct IdmmInterventionRow {
pub id: String,
pub target_kind: String,
pub target_id: String,
pub watch: String,
pub at: i64,
pub signal: String,
pub tier_used: String,
pub category: Option<String>,
pub action: String,
pub detail: Option<String>,
pub reason: Option<String>,
pub confidence: Option<f64>,
pub bypass_model: Option<String>,
pub outcome: String,
}
@@ -0,0 +1,145 @@
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
/// Row in the `knowledge_bases` table — a registered directory of markdown
/// documents. The directory is the source of truth for content; the row only
/// stores registration metadata (the user may drop files in at any time).
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct KnowledgeBaseRow {
pub id: String,
pub name: String,
pub description: String,
/// Absolute root directory of the base.
pub root_path: String,
/// `true` when the directory lives under `{data_dir}/knowledge/{id}` and
/// is owned by us (purge-on-delete allowed); `false` for user-referenced
/// external directories which we never modify structurally.
pub managed: bool,
pub extra: String,
pub created_at: TimestampMs,
pub updated_at: TimestampMs,
/// JSON array of tag keys assigned to this base; NULL = no tags.
/// Deserialized by the service layer, stored opaquely here.
pub tags: Option<String>,
}
/// Row in the `knowledge_bindings` table — which bases a target mounts and
/// whether write-back is allowed. The former composite (target_kind,target_id)
/// PK + JSON `kb_ids` array is redesigned into a surrogate `binding_id` +
/// type-discriminated nullable target columns (exactly one non-null, enforced
/// by a CHECK) + the `knowledge_binding_bases` junction.
/// - `target_workpath`: normalized workspace path key (not an entity, no FK)
/// - `target_conv_id` / `target_term_id`: real TEXT FK (CASCADE)
/// - `target_companion_id`: filesystem companion entity (no FK)
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct KnowledgeBindingRow {
pub binding_id: i64,
pub target_kind: String,
pub target_workpath: Option<String>,
pub target_conv_id: Option<i64>,
pub target_term_id: Option<i64>,
pub target_companion_id: Option<String>,
pub enabled: bool,
pub writeback: bool,
/// `staged` (agent writes confined to `_inbox/{conversation_id}/`,
/// conflict-free across sessions) or `direct` (agent may edit the base
/// body). Only meaningful while `writeback` is true.
pub writeback_mode: String,
/// Write-back disposition ("回写意识"), orthogonal to `writeback_mode`:
/// `conservative` (restrained, the default — only clearly-worth-keeping
/// knowledge) or `aggressive` (capture anything plausibly relevant). Only
/// meaningful while `writeback` is true.
pub writeback_eagerness: String,
/// When `true`, an external IM channel master-agent binding may write back
/// (forced to STAGED placement). Default `false` — channel writes are
/// disabled unless the user explicitly re-enables them. Ignored for
/// non-channel surfaces.
pub channel_write_enabled: bool,
pub updated_at: TimestampMs,
}
impl KnowledgeBindingRow {
/// Resolve the target id for the row's kind (the value the service layer
/// addresses bindings by), as an owned string. `workpath`/`companion` targets are
/// TEXT; `conversation`/`terminal` targets are INTEGER rendered to string.
pub fn target_id(&self) -> Option<String> {
match self.target_kind.as_str() {
"workpath" => self.target_workpath.clone(),
"conversation" => self.target_conv_id.map(|id| id.to_string()),
"terminal" => self.target_term_id.map(|id| id.to_string()),
"companion" => self.target_companion_id.clone(),
_ => None,
}
}
}
/// Row in the `knowledge_tags` table — a user-defined tag definition.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct KnowledgeTagRow {
pub key: String,
pub label: String,
pub color: Option<String>,
pub sort_order: i64,
pub created_at: i64,
}
/// Parameters for creating a knowledge tag.
#[derive(Debug, Clone)]
pub struct CreateKnowledgeTagParams {
pub key: String,
pub label: String,
pub color: Option<String>,
pub sort_order: i64,
pub created_at: i64,
}
/// Parameters for updating a knowledge tag (all fields optional — only non-None
/// fields are written).
#[derive(Debug, Clone, Default)]
pub struct UpdateKnowledgeTagParams {
pub label: Option<String>,
pub color: Option<Option<String>>,
pub sort_order: Option<i64>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn knowledge_rows_roundtrip() {
let base = KnowledgeBaseRow {
id: "kb_1".into(),
name: "领域知识".into(),
description: "测试".into(),
root_path: "C:/data/knowledge/kb_1".into(),
managed: true,
extra: "{}".into(),
created_at: 1,
updated_at: 2,
tags: None,
};
let back: KnowledgeBaseRow = serde_json::from_str(&serde_json::to_string(&base).unwrap()).unwrap();
assert_eq!(back.id, base.id);
assert!(back.managed);
let binding = KnowledgeBindingRow {
binding_id: 7,
target_kind: "conversation".into(),
target_workpath: None,
target_conv_id: Some(1),
target_term_id: None,
target_companion_id: None,
enabled: true,
writeback: false,
writeback_mode: "staged".into(),
writeback_eagerness: "conservative".into(),
channel_write_enabled: false,
updated_at: 3,
};
let back: KnowledgeBindingRow = serde_json::from_str(&serde_json::to_string(&binding).unwrap()).unwrap();
assert!(back.enabled);
assert!(!back.writeback);
assert_eq!(back.target_id(), Some("1".to_string()));
}
}
@@ -0,0 +1,37 @@
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
/// Row mapping for the `mcp_servers` table.
///
/// Enum-like fields (`transport_type`, `status`) are stored as TEXT.
/// The service layer converts them to/from domain enums.
///
/// JSON fields (`transport_config`, `tools`) are stored as TEXT in SQLite
/// and deserialized by the service layer.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct McpServerRow {
pub id: i64,
/// Unique server name (used as identifier when syncing to Agent CLIs).
pub name: String,
pub description: Option<String>,
/// Whether this server is synced to Agent CLIs.
pub enabled: bool,
/// One of: "stdio", "sse", "http".
pub transport_type: String,
/// JSON object: command/args/env (stdio) or url/headers (sse/http).
pub transport_config: String,
/// JSON array of tool descriptions (populated after connection test).
pub tools: Option<String>,
/// One of: "connected", "disconnected", "error", "testing".
/// Represents the latest test result, not a live runtime state.
pub last_test_status: String,
pub last_connected: Option<TimestampMs>,
/// Original JSON text for editing restoration.
pub original_json: Option<String>,
/// Whether this is a built-in server (hidden from edit/delete in UI).
pub builtin: bool,
/// Soft-delete timestamp. `NULL` means active.
pub deleted_at: Option<TimestampMs>,
pub created_at: TimestampMs,
pub updated_at: TimestampMs,
}
@@ -0,0 +1,29 @@
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
/// Row mapping for the `messages` table.
///
/// Enum-like fields (`type`, `position`, `status`) are stored as TEXT strings.
/// The service layer converts them to/from `nomifun_common` enums
/// (`MessageType`, `MessagePosition`, `MessageStatus`).
///
/// The `content` field is a JSON TEXT column deserialized by the service layer.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessageRow {
pub id: String,
pub conversation_id: i64,
/// Source message ID for streaming message merge identification.
pub msg_id: Option<String>,
/// Message type string (e.g. "text", "tips", "tool_call").
#[sqlx(rename = "type")]
pub r#type: String,
/// JSON object: type-specific message content.
pub content: String,
/// One of: "left", "right", "center", "pop".
pub position: Option<String>,
/// One of: "finish", "pending", "error", "work".
pub status: Option<String>,
/// Whether this message is hidden (SQLite INTEGER 0/1).
pub hidden: bool,
pub created_at: TimestampMs,
}
@@ -0,0 +1,73 @@
mod acp_session;
mod agent_metadata;
mod assistant;
mod attachment;
mod audit_log;
mod channel;
mod client_preference;
mod companion_token;
mod connector_credential;
mod conversation;
mod conversation_artifact;
mod cron_job;
mod cron_job_run;
mod domain_config;
mod idmm_intervention;
mod knowledge;
mod mcp_server;
mod message;
mod oauth_token;
mod provider;
mod remote_agent;
mod requirement;
mod skill_tag;
mod system_config;
mod system_settings;
mod tag_setting;
mod team;
mod terminal_session;
mod user;
mod webhook;
mod branding_config;
pub use acp_session::AcpSessionRow;
pub use agent_metadata::{AgentMetadataRow, UpdateAgentHandshakeParams, UpsertAgentMetadataParams};
pub use assistant::{
AssistantOverrideRow, AssistantRow, AssistantTagRow, CreateAssistantParams,
CreateAssistantTagParams, UpdateAssistantParams, UpdateAssistantTagParams,
UpsertOverrideParams,
};
pub use attachment::AttachmentRow;
pub use audit_log::{
AuditCategory, AuditLogRow, AuditStatus, CreateAuditLogParams, PaginatedAuditLogs,
QueryAuditLogParams,
};
pub use channel::{AssistantSessionRow, AssistantUserRow, ChannelPluginRow, PairingCodeRow};
pub use client_preference::ClientPreference;
pub use companion_token::CompanionApiTokenRow;
pub use connector_credential::ConnectorCredentialRow;
pub use conversation::ConversationRow;
pub use conversation_artifact::ConversationArtifactRow;
pub use cron_job::CronJobRow;
pub use cron_job_run::CronJobRunRow;
pub use domain_config::{DomainConfigRow, DomainPreset, DomainPresetCollection, DomainPresetRow, UpdateDomainConfigParams};
pub use idmm_intervention::IdmmInterventionRow;
pub use knowledge::{
CreateKnowledgeTagParams, KnowledgeBaseRow, KnowledgeBindingRow, KnowledgeTagRow,
UpdateKnowledgeTagParams,
};
pub use mcp_server::McpServerRow;
pub use message::MessageRow;
pub use oauth_token::OAuthTokenRow;
pub use provider::Provider;
pub use remote_agent::RemoteAgentRow;
pub use requirement::{RequirementRow, RequirementRowUpdate, RequirementTagRow};
pub use skill_tag::{SkillTagRow, UpsertSkillTagParams};
pub use system_config::{DomainType, InitSystemParams, SystemConfigRow, UpdateSystemConfigParams};
pub use system_settings::SystemSettings;
pub use tag_setting::TagSettingRow;
pub use team::{MailboxMessageRow, TeamAgentRow, TeamRow, TeamTaskDepRow, TeamTaskRow};
pub use terminal_session::TerminalSessionRow;
pub use user::{User, UserRole};
pub use webhook::WebhookRow;
pub use branding_config::{BrandingConfigRow, PresetColors, ThemePreset, UpdateBrandingParams};
@@ -0,0 +1,23 @@
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
/// Row mapping for the `oauth_tokens` table.
///
/// Stores OAuth tokens keyed by MCP server URL.
/// Token values (`access_token`, `refresh_token`) should be stored
/// encrypted; callers handle encryption/decryption.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct OAuthTokenRow {
/// MCP server URL (primary key).
pub server_url: String,
/// Encrypted OAuth access token.
pub access_token: String,
/// Encrypted OAuth refresh token (optional).
pub refresh_token: Option<String>,
/// Token type, typically "bearer".
pub token_type: String,
/// Token expiration timestamp (milliseconds).
pub expires_at: Option<TimestampMs>,
pub created_at: TimestampMs,
pub updated_at: TimestampMs,
}
@@ -0,0 +1,35 @@
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
/// Row mapping for the `providers` table.
///
/// JSON fields (models, capabilities, model_protocols, model_enabled,
/// model_health, bedrock_config) are stored as TEXT in SQLite and
/// deserialized by the service layer.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Provider {
pub id: String,
pub platform: String,
pub name: String,
pub base_url: String,
pub api_key_encrypted: String,
/// JSON array of model ID strings.
pub models: String,
pub enabled: bool,
/// JSON array of capability objects.
pub capabilities: String,
pub context_limit: Option<i64>,
/// JSON object: model_id -> protocol string.
pub model_protocols: Option<String>,
/// JSON object: model_id -> bool.
pub model_enabled: Option<String>,
/// JSON object: model_id -> health status object.
pub model_health: Option<String>,
/// JSON object: Bedrock-specific configuration.
pub bedrock_config: Option<String>,
/// When true, base_url is treated as a complete endpoint URL.
/// The system will NOT append paths like /v1/chat/completions.
pub is_full_url: bool,
pub created_at: TimestampMs,
pub updated_at: TimestampMs,
}
@@ -0,0 +1,40 @@
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
/// Row mapping for the `remote_agents` table.
///
/// Enum-like fields (`protocol`, `auth_type`, `status`) are stored as TEXT.
/// The service layer converts them to/from `nomifun_common` enums
/// (`RemoteAgentProtocol`, `RemoteAgentAuthType`, `RemoteAgentStatus`).
///
/// Sensitive fields (`auth_token`, `device_public_key`, `device_private_key`,
/// `device_token`) are stored AES-encrypted; callers handle encryption/decryption.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct RemoteAgentRow {
pub id: i64,
pub name: String,
/// One of: "openClaw", "zeroClaw", "acp".
pub protocol: String,
pub url: String,
/// One of: "bearer", "password", "none".
pub auth_type: String,
/// AES-encrypted authentication token.
pub auth_token: Option<String>,
/// Whether insecure (non-TLS) connections are allowed.
pub allow_insecure: bool,
pub avatar: Option<String>,
pub description: Option<String>,
/// OpenClaw device identifier.
pub device_id: Option<String>,
/// AES-encrypted Ed25519 public key.
pub device_public_key: Option<String>,
/// AES-encrypted Ed25519 private key.
pub device_private_key: Option<String>,
/// AES-encrypted device token.
pub device_token: Option<String>,
/// One of: "unknown", "connected", "pending", "error".
pub status: String,
pub last_connected_at: Option<TimestampMs>,
pub created_at: TimestampMs,
pub updated_at: TimestampMs,
}
@@ -0,0 +1,74 @@
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
/// Row in the `requirements` table.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct RequirementRow {
pub id: i64,
pub title: String,
pub content: String,
pub tag: String,
pub order_key: String,
pub sort_seq: String,
pub status: String,
pub priority: i64,
pub completion_note: Option<String>,
/// Executing session: a conversation id OR a terminal id, discriminated by
/// `owner_kind`. No FK (dual-domain). Replaces the former `conversation_id`
/// + redundant `claimed_by` columns.
pub owner_session_id: Option<i64>,
/// `'conversation'` | `'terminal'` | NULL (when unowned).
pub owner_kind: Option<String>,
pub claimed_at: Option<TimestampMs>,
pub lease_expires_at: Option<TimestampMs>,
pub started_at: Option<TimestampMs>,
pub completed_at: Option<TimestampMs>,
pub attempt_count: i64,
pub created_by: String,
/// JSON object, forward-compat.
pub extra: String,
pub created_at: TimestampMs,
pub updated_at: TimestampMs,
}
/// Partial update for a requirement row.
///
/// All fields are optional; `None` means "keep the current value".
/// Nullable columns use `Option<Option<T>>`: outer = "change?", inner = "set value or NULL".
#[derive(Debug, Clone, Default)]
pub struct RequirementRowUpdate {
pub title: Option<String>,
pub content: Option<String>,
pub tag: Option<String>,
pub order_key: Option<String>,
pub sort_seq: Option<String>,
pub status: Option<String>,
pub priority: Option<i64>,
pub completion_note: Option<Option<String>>,
pub owner_session_id: Option<Option<i64>>,
pub owner_kind: Option<Option<String>>,
pub claimed_at: Option<Option<TimestampMs>>,
pub lease_expires_at: Option<Option<TimestampMs>>,
pub started_at: Option<Option<TimestampMs>>,
pub completed_at: Option<Option<TimestampMs>>,
pub attempt_count: Option<i64>,
pub extra: Option<String>,
}
/// Row in the `requirement_tags` table: AutoWork tag-level pause state.
/// A tag with no row is treated as not paused.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct RequirementTagRow {
pub tag: String,
/// 0 = active, 1 = paused (SQLite has no bool; stored as INTEGER).
pub paused: i64,
pub paused_reason: Option<String>,
pub paused_req_id: Option<i64>,
pub paused_at: Option<TimestampMs>,
}
impl RequirementTagRow {
pub fn is_paused(&self) -> bool {
self.paused != 0
}
}
@@ -0,0 +1,20 @@
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
/// Row mapping for the `skill_tags` table (user tag assignments per skill).
/// Built-in seed assignments live in skill-tags.json, merged at the route layer.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct SkillTagRow {
pub skill_name: String,
pub audience_tags: Option<String>,
pub scenario_tags: Option<String>,
pub updated_at: TimestampMs,
}
/// Upsert params: JSON-array strings (pre-serialized by the caller).
#[derive(Debug, Clone)]
pub struct UpsertSkillTagParams<'a> {
pub skill_name: &'a str,
pub audience_tags: Option<&'a str>,
pub scenario_tags: Option<&'a str>,
}
@@ -0,0 +1,74 @@
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
/// Domain type enum
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum DomainType {
#[default]
Enterprise,
Government,
Education,
}
impl DomainType {
pub fn as_str(&self) -> &'static str {
match self {
DomainType::Enterprise => "enterprise",
DomainType::Government => "government",
DomainType::Education => "education",
}
}
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"government" => DomainType::Government,
"education" => DomainType::Education,
_ => DomainType::Enterprise,
}
}
pub fn display_name(&self) -> &'static str {
match self {
DomainType::Enterprise => "企业",
DomainType::Government => "政务",
DomainType::Education => "教育",
}
}
}
/// Row mapping for the `system_config` table.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct SystemConfigRow {
pub id: i32,
pub organization_name: String,
pub domain_type: String,
pub initialized: bool,
pub init_completed_at: Option<TimestampMs>,
pub created_at: TimestampMs,
pub updated_at: TimestampMs,
}
impl SystemConfigRow {
pub fn domain(&self) -> DomainType {
DomainType::from_str(&self.domain_type)
}
pub fn is_initialized(&self) -> bool {
self.initialized
}
}
/// Parameters for updating system configuration
#[derive(Debug, Clone, Deserialize)]
pub struct UpdateSystemConfigParams {
pub organization_name: Option<String>,
pub domain_type: Option<String>,
}
/// Parameters for initializing the system
#[derive(Debug, Clone, Deserialize)]
pub struct InitSystemParams {
pub organization_name: String,
pub domain_type: String,
}
@@ -0,0 +1,17 @@
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
/// Row mapping for the `system_settings` table.
///
/// Single-row table (id is always 1). Boolean fields are stored as INTEGER
/// in SQLite (0/1) and mapped to `bool` via sqlx.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct SystemSettings {
pub id: i64,
pub language: String,
pub notification_enabled: bool,
pub cron_notification_enabled: bool,
pub command_queue_enabled: bool,
pub save_upload_to_workspace: bool,
pub updated_at: TimestampMs,
}
@@ -0,0 +1,38 @@
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
/// Row in the `tag_settings` table — per-tag augmentation of the implicit
/// requirement tags (a bound webhook + a description). Tags themselves remain
/// derived from `requirements.tag`; this table only stores extra config keyed by
/// tag name, created on first write.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct TagSettingRow {
pub tag: String,
/// Bound webhook id (`webhooks.id`); `None` means no webhook is bound.
pub webhook_id: Option<i64>,
pub description: String,
/// Comma-separated subset of `done,failed,needs_review` controlling which
/// completion events fire the bound webhook. Defaults to all three.
pub notify_events: String,
pub updated_at: TimestampMs,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tag_setting_row_roundtrips() {
let row = TagSettingRow {
tag: "alpha".into(),
webhook_id: Some(1),
description: "team alpha queue".into(),
notify_events: "done,failed,needs_review".to_string(),
updated_at: 9,
};
let json = serde_json::to_string(&row).unwrap();
let back: TagSettingRow = serde_json::from_str(&json).unwrap();
assert_eq!(back.tag, "alpha");
assert_eq!(back.webhook_id, Some(1));
}
}
@@ -0,0 +1,169 @@
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
/// Row mapping for the `teams` table.
///
/// The former `agents` JSON array is columnized into the `team_agents` table
/// (see [`TeamAgentRow`]). `lead_agent_id` is an agent-address (a slot_id, or
/// the `lead`/`user` sentinel), NOT a foreign key.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct TeamRow {
pub id: String,
pub user_id: String,
pub name: String,
pub workspace: String,
pub workspace_mode: String,
pub lead_agent_id: Option<String>,
pub session_mode: Option<String>,
pub agents_version: String,
pub created_at: TimestampMs,
pub updated_at: TimestampMs,
}
/// Row mapping for the `team_agents` table (was `teams.agents` JSON array).
///
/// `slot_id` stays a string PK because it is transmitted in the MCP env
/// (`TEAM_AGENT_SLOT_ID`) and the remote protocol. `conversation_id` is a real
/// FK (CASCADE); `custom_agent_id` is a soft reference (no FK).
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct TeamAgentRow {
pub slot_id: String,
pub team_id: String,
pub name: String,
pub role: String,
pub conversation_id: Option<i64>,
pub backend: String,
pub model: String,
pub custom_agent_id: Option<String>,
pub status: Option<String>,
pub conversation_type: Option<String>,
pub cli_path: Option<String>,
pub sort_order: i64,
}
/// Row mapping for the `mailbox` table.
///
/// Represents an inter-agent message within a team. `to_agent_id` /
/// `from_agent_id` are agent-addresses (slot_id or `user`/`lead` sentinel),
/// NOT foreign keys.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct MailboxMessageRow {
pub id: i64,
pub team_id: String,
pub to_agent_id: String,
pub from_agent_id: String,
/// Message type: 'message', 'idle_notification', or 'shutdown_request'.
#[sqlx(rename = "type")]
pub msg_type: String,
pub content: String,
pub summary: Option<String>,
/// JSON-serialized file paths attached to the message.
pub files: Option<String>,
pub read: bool,
pub created_at: TimestampMs,
}
/// Row mapping for the `team_tasks` table.
///
/// The former bidirectional `blocked_by` / `blocks` JSON arrays are columnized
/// into the single-directed [`TeamTaskDepRow`] edge table. `owner` is an
/// agent-address (slot_id or sentinel), NOT a foreign key.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct TeamTaskRow {
pub id: String,
pub team_id: String,
pub subject: String,
pub description: Option<String>,
/// Task status: 'pending', 'in_progress', 'completed', or 'deleted'.
pub status: String,
pub owner: Option<String>,
/// JSON object: arbitrary extension metadata.
pub metadata: Option<String>,
pub created_at: TimestampMs,
pub updated_at: TimestampMs,
}
/// Row in the `team_task_deps` edge table (was `team_tasks.blocked_by` /
/// `blocks` JSON arrays). A row means `blocker_task_id` blocks
/// `blocked_task_id`. "who blocks X" = WHERE blocked_task_id=X; "what X blocks"
/// = WHERE blocker_task_id=X.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct TeamTaskDepRow {
pub blocker_task_id: String,
pub blocked_task_id: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn team_agent_row_roundtrip() {
let row = TeamAgentRow {
slot_id: "slot_1".into(),
team_id: "team_1".into(),
name: "Builder".into(),
role: "teammate".into(),
conversation_id: Some(1),
backend: "claude".into(),
model: String::new(),
custom_agent_id: None,
status: Some("idle".into()),
conversation_type: Some("acp".into()),
cli_path: None,
sort_order: 0,
};
let back: TeamAgentRow = serde_json::from_str(&serde_json::to_string(&row).unwrap()).unwrap();
assert_eq!(back.slot_id, "slot_1");
assert_eq!(back.conversation_id, Some(1));
}
#[test]
fn mailbox_row_msg_type_field_maps_correctly() {
let row = MailboxMessageRow {
id: 1,
team_id: "team_1".into(),
to_agent_id: "slot_1".into(),
from_agent_id: "user".into(),
msg_type: "message".into(),
content: "hello".into(),
summary: None,
files: None,
read: false,
created_at: 0,
};
assert_eq!(row.msg_type, "message");
assert_eq!(row.from_agent_id, "user");
}
#[test]
fn team_task_dep_row_roundtrip() {
let dep = TeamTaskDepRow {
blocker_task_id: "task_a".into(),
blocked_task_id: "task_b".into(),
};
let back: TeamTaskDepRow = serde_json::from_str(&serde_json::to_string(&dep).unwrap()).unwrap();
assert_eq!(back.blocker_task_id, "task_a");
assert_eq!(back.blocked_task_id, "task_b");
}
#[test]
fn team_task_row_serialization_roundtrip() {
let row = TeamTaskRow {
id: "task_1".into(),
team_id: "team_1".into(),
subject: "Implement feature".into(),
description: Some("Details".into()),
status: "in_progress".into(),
owner: Some("slot_1".into()),
metadata: Some(r#"{"priority":"high"}"#.into()),
created_at: 1000,
updated_at: 2000,
};
let json = serde_json::to_string(&row).expect("serialize");
let restored: TeamTaskRow = serde_json::from_str(&json).expect("deserialize");
assert_eq!(restored.id, row.id);
assert_eq!(restored.status, row.status);
assert_eq!(restored.owner.as_deref(), Some("slot_1"));
}
}
@@ -0,0 +1,99 @@
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
/// Database row for the `terminal_sessions` table.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct TerminalSessionRow {
pub id: i64,
pub name: String,
pub cwd: String,
pub command: String,
/// JSON array of args.
pub args: String,
/// JSON object of env vars, nullable.
pub env: Option<String>,
pub backend: Option<String>,
pub mode: Option<String>,
pub cols: i64,
pub rows: i64,
pub created_at: TimestampMs,
pub updated_at: TimestampMs,
/// "running" | "exited" | "error".
pub last_status: String,
pub exit_code: Option<i64>,
pub user_id: String,
pub pinned: bool,
pub pinned_at: Option<TimestampMs>,
/// AutoWork config JSON `{enabled, tag, max_requirements}`, nullable. Drives
/// the Requirements Platform AutoWork orchestrator for this terminal.
pub autowork: Option<String>,
/// IDMM config JSON, nullable. When set, the terminal operates under
/// Iterative-Deepening Mental-Model guidance.
pub idmm: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn terminal_session_row_roundtrip() {
let row = TerminalSessionRow {
id: 1,
name: "claude".into(),
cwd: "/work".into(),
command: "claude".into(),
args: r#"["--dangerously-skip-permissions"]"#.into(),
env: Some(r#"{"FOO":"bar"}"#.into()),
backend: Some("claude".into()),
mode: Some("full-auto".into()),
cols: 120,
rows: 40,
created_at: 1000,
updated_at: 2000,
last_status: "running".into(),
exit_code: None,
user_id: "user_1".into(),
pinned: false,
pinned_at: None,
autowork: None,
idmm: None,
};
let json = serde_json::to_string(&row).unwrap();
let restored: TerminalSessionRow = serde_json::from_str(&json).unwrap();
assert_eq!(restored.id, 1);
assert_eq!(restored.cols, 120);
assert_eq!(restored.last_status, "running");
assert!(restored.exit_code.is_none());
}
#[test]
fn terminal_session_row_optional_none() {
let row = TerminalSessionRow {
id: 2,
name: "shell".into(),
cwd: "/tmp".into(),
command: "$SHELL".into(),
args: "[]".into(),
env: None,
backend: None,
mode: None,
cols: 80,
rows: 24,
created_at: 1,
updated_at: 1,
last_status: "exited".into(),
exit_code: Some(0),
user_id: "u".into(),
pinned: true,
pinned_at: Some(123),
autowork: Some(r#"{"enabled":true,"tag":"t"}"#.into()),
idmm: None,
};
assert!(row.env.is_none());
assert!(row.backend.is_none());
assert_eq!(row.exit_code, Some(0));
assert!(row.pinned);
assert!(row.autowork.is_some());
}
}
@@ -0,0 +1,61 @@
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
/// User role enum for RBAC
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum UserRole {
#[default]
User,
Admin,
}
impl UserRole {
pub fn as_str(&self) -> &'static str {
match self {
UserRole::User => "user",
UserRole::Admin => "admin",
}
}
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"admin" => UserRole::Admin,
_ => UserRole::User,
}
}
pub fn is_admin(&self) -> bool {
matches!(self, UserRole::Admin)
}
}
/// Row mapping for the `users` table.
///
/// All fields match the SQLite column names and types exactly.
/// Optional fields correspond to nullable columns.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct User {
pub id: String,
pub username: String,
pub email: Option<String>,
pub password_hash: String,
pub role: String, // 'admin' or 'user'
pub avatar_path: Option<String>,
pub jwt_secret: Option<String>,
pub created_at: TimestampMs,
pub updated_at: TimestampMs,
pub last_login: Option<TimestampMs>,
}
impl User {
/// Get the parsed role
pub fn role(&self) -> UserRole {
UserRole::from_str(&self.role)
}
/// Check if user is admin
pub fn is_admin(&self) -> bool {
self.role().is_admin()
}
}
@@ -0,0 +1,43 @@
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
/// Row in the `webhooks` table — a reusable outbound webhook endpoint.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct WebhookRow {
pub id: i64,
pub name: String,
/// Platform discriminator; `lark` is the only supported value in v1.
pub platform: String,
pub url: String,
/// Optional signing secret (Lark "加签"); never returned to clients.
pub secret: Option<String>,
pub description: String,
pub enabled: bool,
pub created_at: TimestampMs,
pub updated_at: TimestampMs,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn webhook_row_roundtrips() {
let row = WebhookRow {
id: 1,
name: "Team bot".into(),
platform: "lark".into(),
url: "https://open.feishu.cn/open-apis/bot/v2/hook/xxx".into(),
secret: Some("s3cr3t".into()),
description: "notifications".into(),
enabled: true,
created_at: 1,
updated_at: 2,
};
let json = serde_json::to_string(&row).unwrap();
let back: WebhookRow = serde_json::from_str(&json).unwrap();
assert_eq!(back.id, row.id);
assert_eq!(back.platform, "lark");
assert!(back.enabled);
}
}
@@ -0,0 +1,108 @@
//! Repository trait for the `acp_session` table.
//!
//! Each ACP-type conversation owns exactly one `acp_session` row. The
//! row is created alongside the conversation (not on first message) so
//! the runtime-state write path can assume the row exists.
//!
//! `session_config` is a JSON blob that carries everything that is not
//! session identity. Under the `"runtime"` key it holds the user's last
//! per-session choices: current mode, current model, config selections,
//! context usage. `AcpAgentService` updates those fields through
//! [`IAcpSessionRepository::save_runtime_state`] and
//! `AcpAgentManager` preloads them on resume through
//! [`IAcpSessionRepository::load_runtime_state`].
use crate::error::DbError;
use crate::models::AcpSessionRow;
/// Parameters for [`IAcpSessionRepository::create`].
///
/// `session_id` stays `None` until the CLI returns one (first
/// `session/new` or `session/load`), at which point the caller flips
/// it through [`IAcpSessionRepository::update_session_id`].
#[derive(Debug, Clone)]
pub struct CreateAcpSessionParams<'a> {
pub conversation_id: i64,
pub agent_backend: &'a str,
pub agent_source: &'a str,
pub agent_id: &'a str,
}
/// The decoded `session_config.runtime` payload. See module docs.
///
/// All fields are optional because we persist partials — the service
/// may write just the mode or just the usage without touching siblings.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PersistedSessionState {
pub current_mode_id: Option<String>,
pub current_model_id: Option<String>,
/// JSON-encoded map of `config_id -> value`. Stored as a raw string
/// so the repository layer does not have to know the shape.
pub config_selections_json: Option<String>,
/// JSON-encoded `UsageUpdate`. Same rationale as
/// `config_selections_json`.
pub context_usage_json: Option<String>,
}
/// Partial update for [`IAcpSessionRepository::save_runtime_state`].
///
/// `Option<Option<_>>` lets callers distinguish "leave untouched"
/// (outer `None`) from "clear to null" (inner `None`).
#[derive(Debug, Clone, Default)]
pub struct SaveRuntimeStateParams<'a> {
pub current_mode_id: Option<Option<&'a str>>,
pub current_model_id: Option<Option<&'a str>>,
pub config_selections_json: Option<Option<&'a str>>,
pub context_usage_json: Option<Option<&'a str>>,
}
impl SaveRuntimeStateParams<'_> {
pub fn is_empty(&self) -> bool {
self.current_mode_id.is_none()
&& self.current_model_id.is_none()
&& self.config_selections_json.is_none()
&& self.context_usage_json.is_none()
}
}
#[async_trait::async_trait]
pub trait IAcpSessionRepository: Send + Sync {
/// Fetch the full row by conversation id.
async fn get(&self, conversation_id: i64) -> Result<Option<AcpSessionRow>, DbError>;
/// Insert a fresh `acp_session` row. Called by `ConversationService`
/// when an ACP-type conversation is created; primary-key conflict
/// surfaces as `DbError::Conflict`.
async fn create(&self, params: &CreateAcpSessionParams<'_>) -> Result<AcpSessionRow, DbError>;
/// Record the CLI-assigned `session_id` after `session/new` or
/// `session/load` succeeds. Returns `true` when the row existed.
async fn update_session_id(&self, conversation_id: i64, session_id: &str) -> Result<bool, DbError>;
/// Forget the CLI session for a conversation: NULL the `session_id`,
/// reset `session_status` to `idle`, and drop the cached
/// `session_config.runtime.context_usage` so the token meter reflects a
/// fresh start. Used by the "clear context" flow — after this, the next
/// prompt re-issues `session/new` instead of resuming. Returns `true`
/// when the row existed.
async fn clear_session_id(&self, conversation_id: i64) -> Result<bool, DbError>;
/// Delete the row. Called by the conversation delete hook — no DB
/// foreign key, so this must be invoked explicitly.
async fn delete(&self, conversation_id: i64) -> Result<bool, DbError>;
/// Decode and return the `session_config.runtime` sub-object.
/// Returns `None` when the row does not exist or the JSON lacks a
/// `runtime` key; returns `Some(Default::default())` when the key
/// is present but empty.
async fn load_runtime_state(&self, conversation_id: i64) -> Result<Option<PersistedSessionState>, DbError>;
/// Merge a partial runtime update into `session_config.runtime`.
/// Assumes the row exists (created alongside the conversation);
/// returns `Ok(false)` when it does not.
async fn save_runtime_state(
&self,
conversation_id: i64,
params: &SaveRuntimeStateParams<'_>,
) -> Result<bool, DbError>;
}
@@ -0,0 +1,71 @@
//! Repository trait for the `agent_metadata` catalog.
use crate::error::DbError;
use crate::models::{AgentMetadataRow, UpdateAgentHandshakeParams, UpsertAgentMetadataParams};
/// CRUD access for agent metadata rows.
///
/// The table is the single source of truth for how each agent is spawned
/// and what static capabilities it exposes. Handshake-derived fields
/// (`agent_capabilities`, `auth_methods`, `config_options`,
/// `available_modes`, `available_models`, `available_commands`) are
/// refreshed separately via [`IAgentMetadataRepository::apply_handshake`].
#[async_trait::async_trait]
pub trait IAgentMetadataRepository: Send + Sync {
/// Return every row, in insertion order.
async fn list_all(&self) -> Result<Vec<AgentMetadataRow>, DbError>;
/// Look up by primary key.
async fn get(&self, id: &str) -> Result<Option<AgentMetadataRow>, DbError>;
/// Look up by the unique `(agent_source, name)` pair.
async fn find_by_source_and_name(
&self,
agent_source: &str,
name: &str,
) -> Result<Option<AgentMetadataRow>, DbError>;
/// Look up the first `builtin` row whose vendor label matches.
/// Useful when the caller only has the legacy `backend` string and
/// not a full agent id.
async fn find_builtin_by_backend(&self, backend: &str) -> Result<Option<AgentMetadataRow>, DbError>;
/// Insert or replace a row. Returns the row as stored.
async fn upsert(&self, params: &UpsertAgentMetadataParams<'_>) -> Result<AgentMetadataRow, DbError>;
/// Apply handshake-derived fields on top of an existing row.
/// Returns `Ok(None)` if no row matches `id`.
async fn apply_handshake(
&self,
id: &str,
params: &UpdateAgentHandshakeParams<'_>,
) -> Result<Option<AgentMetadataRow>, DbError>;
/// Toggle the `enabled` flag. Returns `true` if a row was updated.
async fn set_enabled(&self, id: &str, enabled: bool) -> Result<bool, DbError>;
/// Overwrite the `behavior_policy` JSON column. Used by the manual
/// "team-capable" override so a user can promote an agent the
/// capability heuristics missed. The caller is responsible for
/// merging on top of the existing policy (this just persists the
/// serialized blob). Returns the updated row, or `Ok(None)` if no
/// row matches `id`.
///
/// Defaulted so the many test-only stub repositories across the
/// workspace need not implement it; the real SQLite repository
/// overrides it. The default is intentionally an error rather than a
/// silent no-op so a production path that forgets to override is
/// loud, not silently broken.
async fn set_behavior_policy(
&self,
id: &str,
_behavior_policy: &str,
) -> Result<Option<AgentMetadataRow>, DbError> {
Err(DbError::Init(format!(
"set_behavior_policy not implemented for this repository (id '{id}')"
)))
}
/// Delete a row. Returns `true` if a row was removed.
async fn delete(&self, id: &str) -> Result<bool, DbError>;
}
@@ -0,0 +1,66 @@
//! Repository traits for the assistants and assistant_overrides tables.
use crate::error::DbError;
use crate::models::{
AssistantOverrideRow, AssistantRow, AssistantTagRow, CreateAssistantParams, CreateAssistantTagParams,
UpdateAssistantParams, UpdateAssistantTagParams, UpsertOverrideParams,
};
/// CRUD access for user-authored assistant rows.
///
/// Object-safe via `async_trait` to support `Arc<dyn IAssistantRepository>`.
#[async_trait::async_trait]
pub trait IAssistantRepository: Send + Sync {
/// Return all user-authored assistants, ordered by `updated_at` descending.
async fn list(&self) -> Result<Vec<AssistantRow>, DbError>;
/// Look up a single assistant by id.
async fn get(&self, id: &str) -> Result<Option<AssistantRow>, DbError>;
/// Insert a new assistant row. Primary-key conflict surfaces as
/// `DbError::Conflict`.
async fn create(&self, params: &CreateAssistantParams<'_>) -> Result<AssistantRow, DbError>;
/// Partial update of an existing assistant row. Returns `Ok(None)` if
/// no row matches.
async fn update(&self, id: &str, params: &UpdateAssistantParams<'_>) -> Result<Option<AssistantRow>, DbError>;
/// Delete an assistant row by id. Returns `true` if a row was removed.
async fn delete(&self, id: &str) -> Result<bool, DbError>;
/// Insert or replace by id. Exists for callers outside of the
/// migration/import path; the import endpoint must use `create` and
/// skip on conflict per spec §6.3.
async fn upsert(&self, params: &CreateAssistantParams<'_>) -> Result<AssistantRow, DbError>;
}
/// Per-assistant user state (enabled flag, sort order, last-used timestamp).
#[async_trait::async_trait]
pub trait IAssistantOverrideRepository: Send + Sync {
/// Fetch the override row for a given assistant id, if any.
async fn get(&self, assistant_id: &str) -> Result<Option<AssistantOverrideRow>, DbError>;
/// Fetch all override rows.
async fn get_all(&self) -> Result<Vec<AssistantOverrideRow>, DbError>;
/// Insert or update the override row for an assistant.
async fn upsert(&self, params: &UpsertOverrideParams<'_>) -> Result<AssistantOverrideRow, DbError>;
/// Delete the override row for an assistant. Returns `true` if a row was
/// removed.
async fn delete(&self, assistant_id: &str) -> Result<bool, DbError>;
/// Remove override rows whose `assistant_id` is not in `valid_ids`.
/// Returns the number of rows deleted.
async fn delete_orphans(&self, valid_ids: &[&str]) -> Result<u64, DbError>;
}
/// CRUD for the user-created assistant tag vocabulary.
#[async_trait::async_trait]
pub trait IAssistantTagRepository: Send + Sync {
async fn list(&self) -> Result<Vec<AssistantTagRow>, DbError>;
async fn get(&self, key: &str) -> Result<Option<AssistantTagRow>, DbError>;
async fn create(&self, params: &CreateAssistantTagParams<'_>) -> Result<AssistantTagRow, DbError>;
async fn update(&self, key: &str, params: &UpdateAssistantTagParams<'_>) -> Result<Option<AssistantTagRow>, DbError>;
async fn delete(&self, key: &str) -> Result<bool, DbError>;
}
@@ -0,0 +1,17 @@
use crate::error::DbError;
use crate::models::AttachmentRow;
/// Data access abstraction for the `attachments` table (requirement images).
#[async_trait::async_trait]
pub trait IAttachmentRepository: Send + Sync {
async fn insert(&self, row: &AttachmentRow) -> Result<(), DbError>;
async fn get_by_id(&self, id: &str) -> Result<Option<AttachmentRow>, DbError>;
/// All attachments for a requirement, oldest first.
async fn list_for_requirement(&self, requirement_id: i64) -> Result<Vec<AttachmentRow>, DbError>;
/// Delete by id. Returns whether a row was deleted (absent id is not an
/// error — callers do best-effort cleanup).
async fn delete(&self, id: &str) -> Result<bool, DbError>;
}
@@ -0,0 +1,17 @@
use crate::error::DbError;
use crate::models::{AuditLogRow, CreateAuditLogParams, PaginatedAuditLogs, QueryAuditLogParams};
/// Repository trait for audit logging.
///
/// Provides append-only audit trail operations for security and compliance.
#[async_trait::async_trait]
pub trait IAuditLogRepository: Send + Sync {
/// Insert a new audit log entry.
async fn create(&self, params: CreateAuditLogParams) -> Result<AuditLogRow, DbError>;
/// Query audit logs with pagination and filtering.
async fn query(&self, params: QueryAuditLogParams) -> Result<PaginatedAuditLogs, DbError>;
/// Get a single audit log entry by ID.
async fn get_by_id(&self, id: i64) -> Result<Option<AuditLogRow>, DbError>;
}
@@ -0,0 +1,61 @@
//! Shared dynamic-bind helpers for repositories that build SQL with a
//! runtime-sized list of parameters.
//!
//! Several repositories (`sqlite_conversation`, `sqlite_cron`,
//! `sqlite_requirement`) assemble `UPDATE ... SET` / filtered `SELECT`
//! statements whose bind count is only known at runtime. They each used to
//! carry a private copy of this `BindValue` tagged union plus the per-query
//! `bind` dispatchers; this module centralizes them so the set of supported
//! bind types stays consistent across repositories.
/// Tagged union to carry heterogeneous bind values for dynamic SQL.
#[derive(Debug, Clone)]
pub(crate) enum BindValue {
Str(String),
OptStr(Option<String>),
Bool(bool),
I64(i64),
OptI64(Option<i64>),
}
/// Binds a `BindValue` to a raw `sqlx::query::Query`.
pub(crate) fn bind_value<'q>(
query: sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>>,
val: &'q BindValue,
) -> sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>> {
match val {
BindValue::Str(s) => query.bind(s.as_str()),
BindValue::OptStr(s) => query.bind(s.as_deref()),
BindValue::Bool(b) => query.bind(*b),
BindValue::I64(n) => query.bind(*n),
BindValue::OptI64(n) => query.bind(*n),
}
}
/// Binds a `BindValue` to a `sqlx::query::QueryAs` (typed row output).
pub(crate) fn bind_value_as<'q, T>(
query: sqlx::query::QueryAs<'q, sqlx::Sqlite, T, sqlx::sqlite::SqliteArguments<'q>>,
val: &'q BindValue,
) -> sqlx::query::QueryAs<'q, sqlx::Sqlite, T, sqlx::sqlite::SqliteArguments<'q>> {
match val {
BindValue::Str(s) => query.bind(s.as_str()),
BindValue::OptStr(s) => query.bind(s.as_deref()),
BindValue::Bool(b) => query.bind(*b),
BindValue::I64(n) => query.bind(*n),
BindValue::OptI64(n) => query.bind(*n),
}
}
/// Binds a `BindValue` to a `sqlx::query::QueryScalar` (single `i64` output).
pub(crate) fn bind_value_scalar<'q>(
query: sqlx::query::QueryScalar<'q, sqlx::Sqlite, i64, sqlx::sqlite::SqliteArguments<'q>>,
val: &'q BindValue,
) -> sqlx::query::QueryScalar<'q, sqlx::Sqlite, i64, sqlx::sqlite::SqliteArguments<'q>> {
match val {
BindValue::Str(s) => query.bind(s.as_str()),
BindValue::OptStr(s) => query.bind(s.as_deref()),
BindValue::Bool(b) => query.bind(*b),
BindValue::I64(n) => query.bind(*n),
BindValue::OptI64(n) => query.bind(*n),
}
}
@@ -0,0 +1,23 @@
use crate::error::DbError;
use crate::models::{BrandingConfigRow, ThemePreset, UpdateBrandingParams};
/// Repository trait for branding configuration.
#[async_trait::async_trait]
pub trait IBrandingConfigRepository: Send + Sync {
/// Get the current branding configuration.
async fn get_config(&self) -> Result<Option<BrandingConfigRow>, DbError>;
/// Update branding configuration with the given params.
async fn update_config(&self, params: UpdateBrandingParams) -> Result<BrandingConfigRow, DbError>;
/// Apply a preset theme.
async fn apply_preset(&self, preset_id: &str) -> Result<BrandingConfigRow, DbError>;
/// Get all available theme presets.
fn get_presets(&self) -> Vec<ThemePreset> {
ThemePreset::all_presets()
}
/// Reset to default preset.
async fn reset_to_default(&self) -> Result<BrandingConfigRow, DbError>;
}
@@ -0,0 +1,124 @@
use nomifun_common::TimestampMs;
use crate::error::DbError;
use crate::models::{AssistantSessionRow, AssistantUserRow, ChannelPluginRow, PairingCodeRow};
/// Data access abstraction for channel integration tables.
///
/// Covers four tables: `assistant_plugins`, `assistant_users`,
/// `assistant_sessions`, and `assistant_pairing_codes`.
///
/// Object-safe via `async_trait` to support `Arc<dyn IChannelRepository>`.
#[async_trait::async_trait]
pub trait IChannelRepository: Send + Sync {
// ── Plugin CRUD ──────────────────────────────────────────────────
/// Returns all registered plugins.
async fn get_all_plugins(&self) -> Result<Vec<ChannelPluginRow>, DbError>;
/// Returns a single plugin by id, or `None` if not found.
async fn get_plugin(&self, id: &str) -> Result<Option<ChannelPluginRow>, DbError>;
/// Inserts a new plugin or updates an existing one (by id).
async fn upsert_plugin(&self, row: &ChannelPluginRow) -> Result<(), DbError>;
/// Updates only the `status` and `last_connected` of a plugin.
async fn update_plugin_status(&self, id: &str, params: &UpdatePluginStatusParams) -> Result<(), DbError>;
/// Updates the companion binding of a plugin row (`None` clears it).
async fn update_plugin_companion(&self, id: &str, companion_id: Option<&str>) -> Result<(), DbError>;
/// Updates the bot identity key of a plugin row (backfill on restore).
async fn update_plugin_bot_key(&self, id: &str, bot_key: &str) -> Result<(), DbError>;
/// Deletes a plugin by id. Returns `DbError::NotFound` if absent.
async fn delete_plugin(&self, id: &str) -> Result<(), DbError>;
// ── User CRUD ────────────────────────────────────────────────────
/// Returns all authorized users.
async fn get_all_users(&self) -> Result<Vec<AssistantUserRow>, DbError>;
/// Finds a user by platform identity scoped to one bot channel.
async fn get_user_by_platform(
&self,
platform_user_id: &str,
platform_type: &str,
channel_id: &str,
) -> Result<Option<AssistantUserRow>, DbError>;
/// Creates a new authorized user record.
async fn create_user(&self, row: &AssistantUserRow) -> Result<(), DbError>;
/// Updates `last_active` timestamp for a user.
async fn update_user_last_active(&self, id: &str, last_active: TimestampMs) -> Result<(), DbError>;
/// Deletes a user by id. Returns `DbError::NotFound` if absent.
/// Associated sessions are cascade-deleted by the database.
async fn delete_user(&self, id: &str) -> Result<(), DbError>;
// ── Session CRUD ─────────────────────────────────────────────────
/// Returns all sessions.
async fn get_all_sessions(&self) -> Result<Vec<AssistantSessionRow>, DbError>;
/// Returns a single session by id.
async fn get_session(&self, id: &str) -> Result<Option<AssistantSessionRow>, DbError>;
/// Finds an existing session by channel + user + chat, or creates a new
/// one. If found, updates `last_activity` and returns the existing row.
/// If not found, inserts `new_row` and returns it.
async fn get_or_create_session(
&self,
user_id: &str,
chat_id: &str,
channel_id: &str,
new_row: &AssistantSessionRow,
) -> Result<AssistantSessionRow, DbError>;
/// Updates `last_activity` timestamp for a session.
async fn update_session_activity(&self, id: &str, last_activity: TimestampMs) -> Result<(), DbError>;
/// Updates the `conversation_id` of a session.
async fn update_session_conversation(&self, id: &str, conversation_id: i64) -> Result<(), DbError>;
/// Updates the `agent_type` of a session.
async fn update_session_agent_type(&self, id: &str, agent_type: &str) -> Result<(), DbError>;
/// Deletes all sessions belonging to a user.
async fn delete_sessions_by_user(&self, user_id: &str) -> Result<(), DbError>;
/// Deletes all sessions that arrived through a channel row.
async fn delete_sessions_by_channel(&self, channel_id: &str) -> Result<(), DbError>;
/// Deletes the session for a specific channel + user + chat triple.
async fn delete_session_by_user_chat(&self, user_id: &str, chat_id: &str, channel_id: &str)
-> Result<(), DbError>;
// ── Pairing Codes ────────────────────────────────────────────────
/// Creates a new pairing code record.
async fn create_pairing(&self, row: &PairingCodeRow) -> Result<(), DbError>;
/// Returns all pairing codes with status = 'pending'.
async fn get_pending_pairings(&self) -> Result<Vec<PairingCodeRow>, DbError>;
/// Retrieves a single pairing code, or `None` if not found.
async fn get_pairing_by_code(&self, code: &str) -> Result<Option<PairingCodeRow>, DbError>;
/// Updates the status of a pairing code.
/// Returns `DbError::NotFound` if the code doesn't exist.
async fn update_pairing_status(&self, code: &str, status: &str) -> Result<(), DbError>;
/// Marks all expired-but-still-pending pairing codes as 'expired'.
/// `now` is the current timestamp in milliseconds.
async fn cleanup_expired_pairings(&self, now: TimestampMs) -> Result<u64, DbError>;
}
/// Parameters for updating plugin runtime status.
#[derive(Debug, Clone, Default)]
pub struct UpdatePluginStatusParams {
pub status: Option<String>,
pub last_connected: Option<TimestampMs>,
pub enabled: Option<bool>,
}
@@ -0,0 +1,21 @@
use crate::error::DbError;
use crate::models::ClientPreference;
/// Client preference data access abstraction.
///
/// Provides CRUD operations on the generic key-value `client_preferences` table.
#[async_trait::async_trait]
pub trait IClientPreferenceRepository: Send + Sync {
/// Returns all client preferences.
async fn get_all(&self) -> Result<Vec<ClientPreference>, DbError>;
/// Returns preferences for the given keys only.
/// Keys that don't exist are simply omitted from the result.
async fn get_by_keys(&self, keys: &[&str]) -> Result<Vec<ClientPreference>, DbError>;
/// Inserts or updates a batch of key-value pairs.
async fn upsert_batch(&self, entries: &[(&str, &str)]) -> Result<(), DbError>;
/// Deletes the given keys.
async fn delete_keys(&self, keys: &[&str]) -> Result<(), DbError>;
}
@@ -0,0 +1,16 @@
use crate::error::DbError;
/// Data access for `companion_access_token`. Each companion has at most one
/// token; only the SHA-256 hash is stored. Used by the Remote capability front
/// door (`/mcp`, `/mcp-agent`, `/v1`).
#[async_trait::async_trait]
pub trait ICompanionTokenRepository: Send + Sync {
/// Every `(companion_id, token_hash)` pair, for boot-time validator hydration.
async fn list_all(&self) -> Result<Vec<(String, String)>, DbError>;
/// Insert or rotate the token hash for one companion (keyed on companion_id).
async fn upsert_for_companion(&self, companion_id: &str, token_hash: &str) -> Result<(), DbError>;
/// Revoke a companion's token. Idempotent (no error when absent).
async fn delete_for_companion(&self, companion_id: &str) -> Result<(), DbError>;
}
@@ -0,0 +1,19 @@
use crate::error::DbError;
use crate::models::ConnectorCredentialRow;
/// Data access for `connector_credentials`. Stores already-encrypted payloads —
/// the service layer handles encryption/decryption (mirrors providers' api_key).
#[async_trait::async_trait]
pub trait IConnectorCredentialRepository: Send + Sync {
/// All credentials, ordered by creation time ascending.
async fn list(&self) -> Result<Vec<ConnectorCredentialRow>, DbError>;
/// One credential by id, or `None`.
async fn get(&self, id: &str) -> Result<Option<ConnectorCredentialRow>, DbError>;
/// Insert a new credential (id generated) and return the stored row.
async fn create(&self, kind: &str, name: &str, payload_encrypted: &str) -> Result<ConnectorCredentialRow, DbError>;
/// Delete by id. `DbError::NotFound` when absent.
async fn delete(&self, id: &str) -> Result<(), DbError>;
}
@@ -0,0 +1,300 @@
use nomifun_common::{PaginatedResult, TimestampMs};
use serde::{Deserialize, Serialize};
use crate::error::DbError;
use crate::models::{ConversationArtifactRow, ConversationRow, MessageRow};
/// Conversation + message data access abstraction.
///
/// Covers conversation CRUD, extended queries (source/chat, cron-job,
/// associated workspace), and message operations (list, insert, update,
/// delete, search).
///
/// Object-safe via `async_trait` to support `Arc<dyn IConversationRepository>`.
#[async_trait::async_trait]
pub trait IConversationRepository: Send + Sync {
// ── Conversation CRUD ───────────────────────────────────────────
/// Returns a conversation by ID, or `None` if not found.
async fn get(&self, id: i64) -> Result<Option<ConversationRow>, DbError>;
/// Inserts a new conversation row. The `id` field of `row` is ignored: the
/// id is allocated by SQLite (INTEGER PK AUTOINCREMENT) and returned.
async fn create(&self, row: &ConversationRow) -> Result<i64, DbError>;
/// Partially updates a conversation. Returns `DbError::NotFound` if ID is missing.
async fn update(&self, id: i64, updates: &ConversationRowUpdate) -> Result<(), DbError>;
/// Deletes a conversation (messages cascade via FK).
/// Returns `DbError::NotFound` if ID is missing.
async fn delete(&self, id: i64) -> Result<(), DbError>;
/// Lists conversations with cursor-based pagination and optional filters.
async fn list_paginated(
&self,
user_id: &str,
filters: &ConversationFilters,
) -> Result<PaginatedResult<ConversationRow>, DbError>;
// ── Extended queries ────────────────────────────────────────────
/// Finds a conversation by source, channel chat ID, and agent type.
async fn find_by_source_and_chat(
&self,
user_id: &str,
source: &str,
chat_id: &str,
agent_type: &str,
) -> Result<Option<ConversationRow>, DbError>;
/// Lists conversations created by the given cron job (`cron_job_id` column).
async fn list_by_cron_job(&self, user_id: &str, cron_job_id: &str) -> Result<Vec<ConversationRow>, DbError>;
/// Lists conversations sharing the same `extra.workspace` value.
/// The conversation identified by `conversation_id` is excluded.
async fn list_associated(&self, user_id: &str, conversation_id: i64) -> Result<Vec<ConversationRow>, DbError>;
// ── conversation_mcp_servers junction ───────────────────────────
/// Returns the MCP server IDs selected for a conversation, ordered by
/// `sort_order`. Replaces the legacy `extra.selected_mcp_server_ids` array.
async fn list_mcp_server_ids(&self, _conversation_id: i64) -> Result<Vec<i64>, DbError> {
Ok(Vec::new())
}
/// Replaces the conversation's selected MCP server set with `ids`, preserving
/// order via `sort_order`. Implemented as a single DELETE + ordered INSERT
/// transaction. Replaces writes to `extra.selected_mcp_server_ids`.
async fn set_mcp_server_ids(&self, _conversation_id: i64, _ids: &[i64]) -> Result<(), DbError> {
Ok(())
}
// ── Message operations ──────────────────────────────────────────
/// Returns paginated messages for a conversation, ordered by `created_at`.
async fn get_messages(
&self,
conv_id: i64,
page: u32,
page_size: u32,
order: SortOrder,
) -> Result<PaginatedResult<MessageRow>, DbError>;
/// Keyset (cursor) pagination: returns up to `limit` messages strictly OLDER
/// than `before` `(created_at, id)`, newest-first (`created_at DESC, id DESC`);
/// `before: None` returns the newest `limit`. `has_more` means an older page
/// exists. Used to incrementally load an ever-growing conversation (e.g. a
/// companion's single session) without fetching the whole transcript, and is
/// stable under concurrent appends (unlike OFFSET). `total` is not computed
/// (returned as 0). Default returns empty so mock repos compile; the SQLite
/// repo overrides it.
async fn get_messages_keyset(
&self,
_conv_id: i64,
_before: Option<(i64, String)>,
_limit: u32,
) -> Result<PaginatedResult<MessageRow>, DbError> {
Ok(PaginatedResult {
items: Vec::new(),
total: 0,
has_more: false,
})
}
/// Returns a single message scoped to a conversation.
async fn get_message(&self, _conv_id: i64, _message_id: &str) -> Result<Option<MessageRow>, DbError> {
Ok(None)
}
/// Inserts a new message row.
async fn insert_message(&self, message: &MessageRow) -> Result<(), DbError>;
/// Partially updates a message. Returns `DbError::NotFound` if ID is missing.
async fn update_message(&self, id: &str, updates: &MessageRowUpdate) -> Result<(), DbError>;
/// Deletes all messages belonging to a conversation.
async fn delete_messages_by_conversation(&self, conv_id: i64) -> Result<(), DbError>;
/// Deletes the message at the `(created_at, id)` keyset cursor (inclusive)
/// and every newer message in the conversation. Returns the number of rows
/// deleted. Default no-op so mock repos compile; SQLite overrides it.
async fn delete_messages_from(
&self,
_conv_id: i64,
_from_created_at: i64,
_from_id: &str,
) -> Result<u64, DbError> {
Ok(0)
}
/// Finds a message by (conversation_id, msg_id, type) triple.
async fn get_message_by_msg_id(
&self,
conv_id: i64,
msg_id: &str,
msg_type: &str,
) -> Result<Option<MessageRow>, DbError>;
/// Full-text search across messages, joining conversation name.
async fn search_messages(
&self,
user_id: &str,
keyword: &str,
page: u32,
page_size: u32,
) -> Result<PaginatedResult<MessageSearchRow>, DbError>;
/// Returns persisted conversation artifacts ordered by `created_at`.
async fn list_artifacts(&self, _conversation_id: i64) -> Result<Vec<ConversationArtifactRow>, DbError> {
Ok(Vec::new())
}
/// Returns a conversation artifact by ID scoped to a conversation.
async fn get_artifact(
&self,
_conversation_id: i64,
_artifact_id: i64,
) -> Result<Option<ConversationArtifactRow>, DbError> {
Ok(None)
}
/// Inserts or updates a conversation artifact.
///
/// Idempotency is keyed by `kind`:
/// - `cron_trigger`: always a fresh INSERT (one row per trigger), returning
/// the row with its auto-assigned `id`.
/// - `skill_suggest`: upsert against the partial UNIQUE
/// `(conversation_id, cron_job_id) WHERE kind = 'skill_suggest'`.
///
/// The `id` field of the input is ignored (it is allocated by SQLite).
async fn upsert_artifact(&self, artifact: &ConversationArtifactRow) -> Result<ConversationArtifactRow, DbError> {
Ok(artifact.clone())
}
/// Updates artifact status and returns the updated row if found.
async fn update_artifact_status(
&self,
_conversation_id: i64,
_artifact_id: i64,
_status: &str,
_updated_at: TimestampMs,
) -> Result<Option<ConversationArtifactRow>, DbError> {
Ok(None)
}
/// Marks all skill suggestion artifacts for a cron job as saved.
async fn mark_skill_suggest_artifacts_saved(
&self,
_cron_job_id: &str,
_updated_at: TimestampMs,
) -> Result<Vec<ConversationArtifactRow>, DbError> {
Ok(Vec::new())
}
/// Deletes all artifacts belonging to a conversation.
async fn delete_artifacts_by_conversation(&self, _conversation_id: i64) -> Result<(), DbError> {
Ok(())
}
/// Returns legacy persisted cron trigger rows so callers can synthesize
/// artifact cards for historical conversations created before artifact migration.
async fn list_legacy_cron_trigger_messages(&self, _conversation_id: i64) -> Result<Vec<MessageRow>, DbError> {
Ok(Vec::new())
}
}
// ── Supporting types ────────────────────────────────────────────────
/// Sort direction for message listing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SortOrder {
#[default]
Asc,
Desc,
}
impl SortOrder {
pub fn as_sql(&self) -> &'static str {
match self {
SortOrder::Asc => "ASC",
SortOrder::Desc => "DESC",
}
}
}
/// Filters for paginated conversation listing.
#[derive(Debug, Clone, Default)]
pub struct ConversationFilters {
/// Cursor: the ID of the last conversation from the previous page.
pub cursor: Option<i64>,
/// Max items per page (default 20).
pub limit: u32,
/// Filter by conversation source.
pub source: Option<String>,
/// Filter by `cron_job_id` column.
pub cron_job_id: Option<String>,
/// Filter by pinned status.
pub pinned: Option<bool>,
/// Exclude companion companion (work-partner) sessions — rows whose
/// `extra.companionSession` is `1`. Used by the companion's own conversation
/// listing/count so its single companion thread does not inflate the
/// "how many conversations" total. Default `false` (companion rows
/// returned, matching the normal `/api/conversations` behavior).
pub exclude_companion_companion: bool,
}
impl ConversationFilters {
pub fn effective_limit(&self) -> u32 {
if self.limit == 0 { 20 } else { self.limit }
}
}
/// Partial update payload for a conversation row.
///
/// `None` = keep existing value; `Some(v)` = set to `v`.
#[derive(Debug, Clone, Default)]
pub struct ConversationRowUpdate {
pub name: Option<String>,
pub pinned: Option<bool>,
pub pinned_at: Option<Option<TimestampMs>>,
pub model: Option<Option<String>>,
pub extra: Option<String>,
pub status: Option<String>,
/// Set/clear the owning cron job. `Some(Some(id))` sets, `Some(None)` clears
/// (used by the cron executor's atomic backfill on `new_conversation`).
pub cron_job_id: Option<Option<String>>,
pub updated_at: Option<TimestampMs>,
}
/// Partial update payload for a message row.
#[derive(Debug, Clone, Default)]
pub struct MessageRowUpdate {
pub content: Option<String>,
pub status: Option<Option<String>>,
pub hidden: Option<bool>,
}
/// A single result row from cross-conversation message search.
/// Includes full conversation fields for building nested response.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessageSearchRow {
// Message fields
pub message_id: String,
#[sqlx(rename = "type")]
pub r#type: String,
pub content: String,
pub created_at: TimestampMs,
// Conversation fields
pub conversation_id: i64,
pub conversation_name: String,
pub conversation_type: String,
pub conversation_extra: String,
pub conversation_model: Option<String>,
pub conversation_status: Option<String>,
pub conversation_source: Option<String>,
pub conversation_channel_chat_id: Option<String>,
pub conversation_pinned: bool,
pub conversation_pinned_at: Option<TimestampMs>,
pub conversation_created_at: TimestampMs,
pub conversation_updated_at: TimestampMs,
}
@@ -0,0 +1,82 @@
use nomifun_common::TimestampMs;
use crate::error::DbError;
use crate::models::{CronJobRow, CronJobRunRow};
pub const CRON_RUN_HISTORY_LIMIT: i64 = 7;
/// Parameters for updating a cron job.
///
/// All fields are optional; `None` means "keep the current value".
#[derive(Debug, Clone, Default)]
pub struct UpdateCronJobParams {
pub name: Option<String>,
pub enabled: Option<bool>,
pub schedule_kind: Option<String>,
pub schedule_value: Option<String>,
pub schedule_tz: Option<Option<String>>,
pub schedule_description: Option<Option<String>>,
pub payload_message: Option<String>,
pub execution_mode: Option<String>,
pub agent_config: Option<Option<String>>,
/// Target conversation. `Some(Some(id))` binds a conversation, `Some(None)`
/// clears it to NULL (FK ON DELETE SET NULL), `None` leaves it unchanged.
pub conversation_id: Option<Option<i64>>,
pub conversation_title: Option<Option<String>>,
pub agent_type: Option<String>,
pub skill_content: Option<Option<String>>,
pub description: Option<Option<String>>,
pub next_run_at: Option<Option<TimestampMs>>,
pub last_run_at: Option<Option<TimestampMs>>,
pub last_status: Option<Option<String>>,
pub last_error: Option<Option<String>>,
pub run_count: Option<i64>,
pub retry_count: Option<i64>,
pub target_kind: Option<String>,
pub terminal_mode: Option<Option<String>>,
pub terminal_session_id: Option<Option<i64>>,
pub terminal_command: Option<Option<String>>,
pub terminal_args: Option<Option<String>>,
pub terminal_script: Option<Option<String>>,
}
/// Data access abstraction for the `cron_jobs` table.
#[async_trait::async_trait]
pub trait ICronRepository: Send + Sync {
/// Inserts a new cron job row.
async fn insert(&self, row: &CronJobRow) -> Result<(), DbError>;
/// Updates a cron job by ID with the provided fields.
/// Returns `DbError::NotFound` if absent.
async fn update(&self, id: &str, params: &UpdateCronJobParams) -> Result<(), DbError>;
/// Deletes a cron job by ID. Returns `DbError::NotFound` if absent.
async fn delete(&self, id: &str) -> Result<(), DbError>;
/// Returns a single cron job by ID, or `None` if not found.
async fn get_by_id(&self, id: &str) -> Result<Option<CronJobRow>, DbError>;
/// Returns all cron jobs ordered by creation time ascending.
async fn list_all(&self) -> Result<Vec<CronJobRow>, DbError>;
/// Returns all enabled cron jobs.
async fn list_enabled(&self) -> Result<Vec<CronJobRow>, DbError>;
/// Returns all cron jobs for a given conversation.
async fn list_by_conversation(&self, conversation_id: i64) -> Result<Vec<CronJobRow>, DbError>;
/// Deletes all cron jobs associated with a conversation.
/// Returns the number of deleted rows.
async fn delete_by_conversation(&self, conversation_id: i64) -> Result<u64, DbError>;
/// Inserts one execution record and prunes older rows for the same job so
/// each job retains at most [`CRON_RUN_HISTORY_LIMIT`] rows.
async fn insert_run_pruned(&self, row: &CronJobRunRow) -> Result<(), DbError>;
/// Returns recent execution records for one job, newest first.
async fn list_runs_by_job(
&self,
job_id: &str,
limit: i64,
) -> Result<Vec<CronJobRunRow>, DbError>;
}
@@ -0,0 +1,21 @@
use crate::error::DbError;
use crate::models::{DomainConfigRow, DomainPreset, UpdateDomainConfigParams};
/// Repository trait for domain configuration.
///
/// The `domain_config` table holds a single row (id=1).
#[async_trait::async_trait]
pub trait IDomainConfigRepository: Send + Sync {
/// Returns the domain config row, or `None` if no row exists.
async fn get_config(&self) -> Result<Option<DomainConfigRow>, DbError>;
/// Partial update of domain configuration fields.
async fn update_config(&self, params: UpdateDomainConfigParams) -> Result<DomainConfigRow, DbError>;
/// Get all domain presets, ordered by sort_order.
async fn get_presets(&self) -> Result<Vec<DomainPreset>, DbError>;
/// Apply a preset by ID: reads the preset row, merges its settings into
/// the domain config row, and returns the updated config.
async fn apply_preset(&self, preset_id: &str) -> Result<DomainConfigRow, DbError>;
}
@@ -0,0 +1,37 @@
use crate::error::DbError;
use crate::models::IdmmInterventionRow;
/// Data access for `idmm_interventions`. Aggressive eviction lives here:
/// `insert` prunes the target down to PER_TARGET_CAP after writing.
#[async_trait::async_trait]
pub trait IIdmmInterventionRepository: Send + Sync {
/// Insert one record, then prune this target to the most-recent PER_TARGET_CAP.
async fn insert(&self, row: &IdmmInterventionRow) -> Result<(), DbError>;
/// Most-recent-first, capped at `limit`.
async fn list_for_target(
&self,
target_kind: &str,
target_id: &str,
limit: i64,
) -> Result<Vec<IdmmInterventionRow>, DbError>;
/// Delete all records for a target (manual clear + session-delete cascade). Returns count.
async fn delete_for_target(&self, target_kind: &str, target_id: &str) -> Result<u64, DbError>;
/// Most-recent-first across ALL targets, capped at `limit` (cross-session feed).
async fn list_recent(&self, limit: i64) -> Result<Vec<IdmmInterventionRow>, DbError>;
/// Delete every record across all targets. Returns count.
async fn clear_all(&self) -> Result<u64, DbError>;
/// TTL sweep: delete rows older than `cutoff_ms` + enforce global hard cap. Returns count.
async fn sweep(&self, cutoff_ms: i64, global_cap: i64) -> Result<u64, DbError>;
}
/// Keep only the newest 30 records per target (data is disposable).
pub const PER_TARGET_CAP: i64 = 30;
/// TTL: 48 hours.
pub const TTL_MS: i64 = 48 * 60 * 60 * 1000;
/// Global backstop.
pub const GLOBAL_CAP: i64 = 2000;
@@ -0,0 +1,87 @@
use crate::error::DbError;
use crate::models::{CreateKnowledgeTagParams, KnowledgeBaseRow, KnowledgeBindingRow, KnowledgeTagRow, UpdateKnowledgeTagParams};
/// Data access abstraction for the `knowledge_bases` / `knowledge_bindings` /
/// `knowledge_binding_bases` tables.
///
/// Bases are global (not per-user), mirroring webhooks: a shared pool of
/// knowledge directories reused across sessions. Bindings are addressed by
/// `(target_kind, target_id)`; internally the former composite PK + JSON
/// `kb_ids` array are redesigned into a surrogate `binding_id` +
/// type-discriminated nullable target columns (CHECK exactly-one) + the
/// `knowledge_binding_bases` junction. The `(target_kind, target_id)` pair the
/// service addresses bindings by maps to the matching `target_*` column per
/// the `target_kind` discriminator (`workpath`/`conversation`/`terminal`/`companion`).
#[async_trait::async_trait]
pub trait IKnowledgeRepository: Send + Sync {
/// Insert a new knowledge base row.
async fn insert_base(&self, row: &KnowledgeBaseRow) -> Result<(), DbError>;
/// Replace the mutable columns (name/description/extra/updated_at) of an
/// existing base. Returns `DbError::NotFound` if absent.
async fn update_base(&self, row: &KnowledgeBaseRow) -> Result<(), DbError>;
/// Delete a base by id. Returns `DbError::NotFound` if absent.
async fn delete_base(&self, id: &str) -> Result<(), DbError>;
/// Return a single base by id, or `None`.
async fn get_base(&self, id: &str) -> Result<Option<KnowledgeBaseRow>, DbError>;
/// Return all bases ordered by creation time ascending (stable list order).
async fn list_bases(&self) -> Result<Vec<KnowledgeBaseRow>, DbError>;
/// Return the binding for a target (the `knowledge_bindings` row plus its
/// ordered `kb_id` list from the junction), or `None` when never
/// configured. The `Vec<String>` is sorted by `knowledge_binding_bases.position`.
async fn get_binding(
&self,
target_kind: &str,
target_id: &str,
) -> Result<Option<(KnowledgeBindingRow, Vec<String>)>, DbError>;
/// Insert-or-replace the binding for a target in one transaction:
/// 1. upsert the `knowledge_bindings` row (the `target_id` is written to
/// the column selected by `target_kind`), obtaining `binding_id`;
/// 2. clear and re-insert `knowledge_binding_bases` for `kb_ids`,
/// preserving order via `position`.
/// Returns the (possibly newly allocated) `binding_id`.
#[allow(clippy::too_many_arguments)]
async fn set_binding(
&self,
target_kind: &str,
target_id: &str,
kb_ids: &[String],
enabled: bool,
writeback: bool,
writeback_mode: &str,
writeback_eagerness: &str,
channel_write_enabled: bool,
updated_at: nomifun_common::TimestampMs,
) -> Result<i64, DbError>;
/// Delete the binding for a target (no-op when absent). Used by the
/// conversation-delete hook so bindings don't accumulate as orphans. The
/// `knowledge_binding_bases` rows are removed automatically by FK CASCADE.
async fn delete_binding(&self, target_kind: &str, target_id: &str) -> Result<(), DbError>;
/// All bindings that reference `kb_id` (via the `knowledge_binding_bases`
/// junction), enabled or not. Powers the "who is using this base?"
/// consumers view. Ordered by `target_kind` then `binding_id` for stable
/// display.
async fn list_bindings_using_kb(&self, kb_id: &str) -> Result<Vec<KnowledgeBindingRow>, DbError>;
// ── Knowledge tags (user-defined tag palette) ─────────────────────────
/// Return all tag definitions ordered by `sort_order` ascending, then `key`.
async fn list_knowledge_tags(&self) -> Result<Vec<KnowledgeTagRow>, DbError>;
/// Insert a new tag definition.
async fn create_knowledge_tag(&self, params: CreateKnowledgeTagParams) -> Result<(), DbError>;
/// Update mutable fields of an existing tag. Returns `DbError::NotFound`
/// if no tag with `key` exists.
async fn update_knowledge_tag(&self, key: &str, params: UpdateKnowledgeTagParams) -> Result<(), DbError>;
/// Delete a tag by key. Returns `DbError::NotFound` if absent.
async fn delete_knowledge_tag(&self, key: &str) -> Result<(), DbError>;
}
@@ -0,0 +1,104 @@
use crate::error::DbError;
use crate::models::McpServerRow;
/// MCP server configuration data access abstraction.
///
/// Provides CRUD operations, batch upsert, and name-based lookup
/// on the `mcp_servers` table. JSON fields (`transport_config`, `tools`)
/// are opaque strings at this layer; the service layer handles
/// serialization/deserialization.
///
/// Object-safe via `async_trait` to support `Arc<dyn IMcpServerRepository>`.
#[async_trait::async_trait]
pub trait IMcpServerRepository: Send + Sync {
/// Returns all MCP servers, ordered by creation time ascending.
async fn list(&self) -> Result<Vec<McpServerRow>, DbError>;
/// Finds an MCP server by ID, or `None` if not found.
async fn find_by_id(&self, id: i64) -> Result<Option<McpServerRow>, DbError>;
/// Finds an MCP server by name, or `None` if not found.
async fn find_by_name(&self, name: &str) -> Result<Option<McpServerRow>, DbError>;
/// Finds an MCP server by ID, including soft-deleted rows.
async fn find_by_id_any(&self, id: i64) -> Result<Option<McpServerRow>, DbError> {
self.find_by_id(id).await
}
/// Finds an MCP server by name, including soft-deleted rows.
async fn find_by_name_any(&self, name: &str) -> Result<Option<McpServerRow>, DbError> {
self.find_by_name(name).await
}
/// Finds a set of MCP servers by ID, including soft-deleted rows.
async fn list_by_ids_any(&self, ids: &[i64]) -> Result<Vec<McpServerRow>, DbError> {
let mut rows = Vec::with_capacity(ids.len());
for &id in ids {
if let Some(row) = self.find_by_id_any(id).await? {
rows.push(row);
}
}
Ok(rows)
}
/// Creates a new MCP server and returns the inserted row.
/// Returns `DbError::Conflict` if the name already exists.
async fn create(&self, params: CreateMcpServerParams<'_>) -> Result<McpServerRow, DbError>;
/// Updates an existing MCP server. Returns `DbError::NotFound` if the ID
/// doesn't exist, `DbError::Conflict` if the new name collides with another.
async fn update(&self, id: i64, params: UpdateMcpServerParams<'_>) -> Result<McpServerRow, DbError>;
/// Soft-deletes an MCP server by ID. Returns `DbError::NotFound` if the ID
/// doesn't exist.
async fn delete(&self, id: i64) -> Result<(), DbError>;
/// Upserts multiple servers by name: existing names are updated,
/// new names are inserted. Returns the count of affected rows.
async fn batch_upsert(&self, servers: &[CreateMcpServerParams<'_>]) -> Result<Vec<McpServerRow>, DbError>;
/// Updates only the latest connection-test result status
/// (and optionally `last_connected`).
/// Returns `DbError::NotFound` if the ID doesn't exist.
async fn update_status(
&self,
id: i64,
status: &str,
last_connected: Option<nomifun_common::TimestampMs>,
) -> Result<(), DbError>;
/// Updates only the tools JSON for a server.
/// Returns `DbError::NotFound` if the ID doesn't exist.
async fn update_tools(&self, id: i64, tools: Option<&str>) -> Result<(), DbError>;
}
/// Parameters for creating a new MCP server.
#[derive(Debug, Clone)]
pub struct CreateMcpServerParams<'a> {
pub name: &'a str,
pub description: Option<&'a str>,
pub enabled: bool,
pub transport_type: &'a str,
pub transport_config: &'a str,
pub tools: Option<&'a str>,
pub original_json: Option<&'a str>,
pub builtin: bool,
}
/// Parameters for updating an existing MCP server.
///
/// All fields are optional; `None` means "keep the current value".
/// For nullable fields, `Some(None)` means "clear the value" and
/// `Some(Some(v))` means "set to v".
#[derive(Debug, Default)]
pub struct UpdateMcpServerParams<'a> {
pub name: Option<&'a str>,
pub description: Option<Option<&'a str>>,
pub enabled: Option<bool>,
pub transport_type: Option<&'a str>,
pub transport_config: Option<&'a str>,
pub tools: Option<Option<&'a str>>,
pub original_json: Option<Option<&'a str>>,
pub builtin: Option<bool>,
pub deleted_at: Option<Option<nomifun_common::TimestampMs>>,
}
@@ -0,0 +1,114 @@
pub mod acp_session;
pub mod agent_metadata;
mod bind;
pub mod audit_log;
pub mod branding_config;
pub mod sqlite_audit_log;
pub mod assistant;
pub mod attachment;
pub mod channel;
pub mod connector_credential;
mod client_preference;
pub mod conversation;
pub mod cron;
pub mod idmm_intervention;
pub mod companion_token;
pub mod knowledge;
pub mod mcp_server;
pub mod oauth_token;
pub mod provider;
pub mod remote_agent;
pub mod requirement;
mod settings;
pub mod skill_tag;
mod sqlite_acp_session;
mod sqlite_agent_metadata;
pub mod system_config;
mod sqlite_assistant;
mod sqlite_attachment;
mod sqlite_channel;
mod sqlite_client_preference;
mod sqlite_connector_credential;
mod sqlite_conversation;
mod sqlite_cron;
mod sqlite_idmm_intervention;
mod sqlite_companion_token;
mod sqlite_knowledge;
mod sqlite_mcp_server;
mod sqlite_oauth_token;
mod sqlite_provider;
mod sqlite_remote_agent;
mod sqlite_requirement;
mod sqlite_settings;
mod sqlite_skill_tag;
pub mod sqlite_system_config;
pub mod domain_config;
pub mod sqlite_domain_config;
pub mod sqlite_branding_config;
mod sqlite_tag_setting;
mod sqlite_team;
mod sqlite_terminal;
mod sqlite_user;
mod sqlite_webhook;
pub mod tag_setting;
pub mod team;
pub mod terminal;
mod user;
pub mod webhook;
pub use acp_session::{CreateAcpSessionParams, IAcpSessionRepository, PersistedSessionState, SaveRuntimeStateParams};
pub use agent_metadata::IAgentMetadataRepository;
pub use assistant::{IAssistantOverrideRepository, IAssistantRepository, IAssistantTagRepository};
pub use attachment::IAttachmentRepository;
pub use channel::IChannelRepository;
pub use client_preference::IClientPreferenceRepository;
pub use connector_credential::IConnectorCredentialRepository;
pub use conversation::IConversationRepository;
pub use cron::ICronRepository;
pub use idmm_intervention::{GLOBAL_CAP, IIdmmInterventionRepository, PER_TARGET_CAP, TTL_MS};
pub use companion_token::ICompanionTokenRepository;
pub use knowledge::IKnowledgeRepository;
pub use mcp_server::IMcpServerRepository;
pub use oauth_token::IOAuthTokenRepository;
pub use provider::IProviderRepository;
pub use remote_agent::IRemoteAgentRepository;
pub use requirement::{IRequirementRepository, ListRequirementsParams};
pub use settings::ISettingsRepository;
pub use skill_tag::ISkillTagRepository;
pub use sqlite_acp_session::SqliteAcpSessionRepository;
pub use sqlite_agent_metadata::SqliteAgentMetadataRepository;
pub use sqlite_assistant::{SqliteAssistantOverrideRepository, SqliteAssistantRepository, SqliteAssistantTagRepository};
pub use sqlite_attachment::SqliteAttachmentRepository;
pub use sqlite_channel::SqliteChannelRepository;
pub use sqlite_client_preference::SqliteClientPreferenceRepository;
pub use sqlite_connector_credential::SqliteConnectorCredentialRepository;
pub use sqlite_conversation::SqliteConversationRepository;
pub use sqlite_cron::SqliteCronRepository;
pub use sqlite_idmm_intervention::SqliteIdmmInterventionRepository;
pub use sqlite_companion_token::SqliteCompanionTokenRepository;
pub use sqlite_knowledge::SqliteKnowledgeRepository;
pub use sqlite_mcp_server::SqliteMcpServerRepository;
pub use sqlite_oauth_token::SqliteOAuthTokenRepository;
pub use sqlite_provider::SqliteProviderRepository;
pub use sqlite_remote_agent::SqliteRemoteAgentRepository;
pub use sqlite_requirement::SqliteRequirementRepository;
pub use sqlite_settings::SqliteSettingsRepository;
pub use sqlite_skill_tag::SqliteSkillTagRepository;
pub use sqlite_system_config::SqliteSystemConfigRepository;
pub use domain_config::IDomainConfigRepository;
pub use sqlite_domain_config::SqliteDomainConfigRepository;
pub use sqlite_branding_config::SqliteBrandingConfigRepository;
pub use sqlite_audit_log::SqliteAuditLogRepository;
pub use audit_log::IAuditLogRepository;
pub use branding_config::IBrandingConfigRepository;
pub use sqlite_tag_setting::SqliteTagSettingRepository;
pub use system_config::ISystemConfigRepository;
pub use sqlite_team::SqliteTeamRepository;
pub use sqlite_terminal::SqliteTerminalRepository;
pub use sqlite_user::SqliteUserRepository;
pub use sqlite_webhook::SqliteWebhookRepository;
pub use tag_setting::ITagSettingRepository;
pub use team::ITeamRepository;
pub use terminal::{CreateTerminalParams, ITerminalRepository};
pub use user::IUserRepository;
pub use webhook::IWebhookRepository;
@@ -0,0 +1,34 @@
use crate::error::DbError;
use crate::models::OAuthTokenRow;
/// OAuth token data access abstraction for MCP server authentication.
///
/// Provides upsert/get/delete operations keyed by server URL.
/// Token values are stored encrypted; callers handle encryption/decryption.
///
/// Object-safe via `async_trait` to support `Arc<dyn IOAuthTokenRepository>`.
#[async_trait::async_trait]
pub trait IOAuthTokenRepository: Send + Sync {
/// Gets a token by server URL, or `None` if not found.
async fn get_by_url(&self, server_url: &str) -> Result<Option<OAuthTokenRow>, DbError>;
/// Inserts or updates a token for the given server URL.
async fn upsert(&self, params: UpsertOAuthTokenParams<'_>) -> Result<OAuthTokenRow, DbError>;
/// Deletes a token by server URL. Returns `DbError::NotFound` if the URL
/// doesn't exist.
async fn delete(&self, server_url: &str) -> Result<(), DbError>;
/// Returns the list of server URLs that have stored tokens.
async fn list_authenticated_urls(&self) -> Result<Vec<String>, DbError>;
}
/// Parameters for inserting or updating an OAuth token.
#[derive(Debug)]
pub struct UpsertOAuthTokenParams<'a> {
pub server_url: &'a str,
pub access_token: &'a str,
pub refresh_token: Option<&'a str>,
pub token_type: &'a str,
pub expires_at: Option<nomifun_common::TimestampMs>,
}
@@ -0,0 +1,64 @@
use crate::error::DbError;
use crate::models::Provider;
/// Model provider data access abstraction.
///
/// Provides CRUD operations on the `providers` table.
/// API keys are stored encrypted; callers handle encryption/decryption.
#[async_trait::async_trait]
pub trait IProviderRepository: Send + Sync {
/// Returns all providers, ordered by creation time ascending.
async fn list(&self) -> Result<Vec<Provider>, DbError>;
/// Finds a provider by ID, or `None` if not found.
async fn find_by_id(&self, id: &str) -> Result<Option<Provider>, DbError>;
/// Creates a new provider and returns the inserted row.
async fn create(&self, params: CreateProviderParams<'_>) -> Result<Provider, DbError>;
/// Updates an existing provider. Returns `DbError::NotFound` if the ID doesn't exist.
async fn update(&self, id: &str, params: UpdateProviderParams<'_>) -> Result<Provider, DbError>;
/// Deletes a provider by ID. Returns `DbError::NotFound` if the ID doesn't exist.
async fn delete(&self, id: &str) -> Result<(), DbError>;
}
/// Parameters for creating a new provider.
#[derive(Debug)]
pub struct CreateProviderParams<'a> {
/// Optional caller-supplied id. When `None`, the repository generates one.
pub id: Option<&'a str>,
pub platform: &'a str,
pub name: &'a str,
pub base_url: &'a str,
pub api_key_encrypted: &'a str,
pub models: &'a str,
pub enabled: bool,
pub capabilities: &'a str,
pub context_limit: Option<i64>,
pub model_protocols: Option<&'a str>,
pub model_enabled: Option<&'a str>,
pub model_health: Option<&'a str>,
pub bedrock_config: Option<&'a str>,
pub is_full_url: bool,
}
/// Parameters for updating an existing provider.
///
/// All fields are optional; `None` means "keep the current value".
#[derive(Debug, Default)]
pub struct UpdateProviderParams<'a> {
pub platform: Option<&'a str>,
pub name: Option<&'a str>,
pub base_url: Option<&'a str>,
pub api_key_encrypted: Option<&'a str>,
pub models: Option<&'a str>,
pub enabled: Option<bool>,
pub capabilities: Option<&'a str>,
pub context_limit: Option<Option<i64>>,
pub model_protocols: Option<Option<&'a str>>,
pub model_enabled: Option<Option<&'a str>>,
pub model_health: Option<Option<&'a str>>,
pub bedrock_config: Option<Option<&'a str>>,
pub is_full_url: Option<bool>,
}
@@ -0,0 +1,70 @@
use nomifun_common::TimestampMs;
use crate::error::DbError;
use crate::models::RemoteAgentRow;
/// Remote Agent configuration data access abstraction.
///
/// Provides CRUD operations and status management on the `remote_agents` table.
/// Sensitive fields (auth_token, device keys) are stored encrypted; callers
/// handle encryption/decryption before passing data in.
#[async_trait::async_trait]
pub trait IRemoteAgentRepository: Send + Sync {
/// Returns all remote agents, ordered by creation time ascending.
async fn list(&self) -> Result<Vec<RemoteAgentRow>, DbError>;
/// Finds a remote agent by ID, or `None` if not found.
async fn find_by_id(&self, id: i64) -> Result<Option<RemoteAgentRow>, DbError>;
/// Creates a new remote agent and returns the inserted row.
async fn create(&self, params: CreateRemoteAgentParams<'_>) -> Result<RemoteAgentRow, DbError>;
/// Updates an existing remote agent. Returns `DbError::NotFound` if the ID doesn't exist.
async fn update(&self, id: i64, params: UpdateRemoteAgentParams<'_>) -> Result<RemoteAgentRow, DbError>;
/// Deletes a remote agent by ID. Returns `DbError::NotFound` if the ID doesn't exist.
async fn delete(&self, id: i64) -> Result<(), DbError>;
/// Updates only the connection status (and optionally last_connected_at).
/// Returns `DbError::NotFound` if the ID doesn't exist.
async fn update_status(
&self,
id: i64,
status: &str,
last_connected_at: Option<TimestampMs>,
) -> Result<(), DbError>;
}
/// Parameters for creating a new remote agent.
#[derive(Debug)]
pub struct CreateRemoteAgentParams<'a> {
pub name: &'a str,
pub protocol: &'a str,
pub url: &'a str,
pub auth_type: &'a str,
pub auth_token: Option<&'a str>,
pub allow_insecure: bool,
pub avatar: Option<&'a str>,
pub description: Option<&'a str>,
pub device_id: Option<&'a str>,
pub device_public_key: Option<&'a str>,
pub device_private_key: Option<&'a str>,
pub device_token: Option<&'a str>,
}
/// Parameters for updating an existing remote agent.
///
/// All fields are optional; `None` means "keep the current value".
/// For nullable fields, `Some(None)` means "clear the value" and
/// `Some(Some(v))` means "set to v".
#[derive(Debug, Default)]
pub struct UpdateRemoteAgentParams<'a> {
pub name: Option<&'a str>,
pub protocol: Option<&'a str>,
pub url: Option<&'a str>,
pub auth_type: Option<&'a str>,
pub auth_token: Option<Option<&'a str>>,
pub allow_insecure: Option<bool>,
pub avatar: Option<Option<&'a str>>,
pub description: Option<Option<&'a str>>,
}
@@ -0,0 +1,126 @@
use nomifun_common::TimestampMs;
use crate::error::DbError;
use crate::models::{RequirementRow, RequirementRowUpdate, RequirementTagRow};
/// Filters + pagination for listing requirements.
#[derive(Debug, Clone, Default)]
pub struct ListRequirementsParams {
pub tag: Option<String>,
pub status: Option<String>,
/// Filter by the executing session (a conversation or terminal id). Matches
/// the `owner_session_id` column (`idx_requirements_owner`).
pub owner_session_id: Option<i64>,
/// Filter by the owner domain (`"conversation"` | `"terminal"`). Paired with
/// `owner_session_id` it disambiguates the dual-domain owner column — after
/// integerization a conversation and a terminal can share a numeric id, so a
/// session-scoped query (e.g. clearing a deleted session's requirements) MUST
/// constrain `owner_kind` too, or it crosses domains (spec §2.2).
pub owner_kind: Option<String>,
/// Substring search over title + content (case-insensitive).
pub q: Option<String>,
/// Sort column (whitelisted in the repository). Recognized values:
/// `"id" | "created_at" | "updated_at" | "status"`. Any other value — or
/// `None` — falls back to the default queue order
/// (`sort_seq ASC, priority DESC, created_at ASC`). User input is never
/// interpolated into SQL; it only selects a fixed, hard-coded column.
pub order_by: Option<String>,
/// Sort direction: `"asc" | "desc"`. Defaults to `desc` for an explicit
/// `order_by`. Ignored when `order_by` is absent/unrecognized.
pub order: Option<String>,
/// 1-based page index. Defaults to 1 when None.
pub page: Option<u32>,
/// Page size. Defaults to 20 when None.
pub page_size: Option<u32>,
}
/// Data access abstraction for the `requirements` table.
#[async_trait::async_trait]
pub trait IRequirementRepository: Send + Sync {
/// Insert a new requirement row. The `id` field of `row` is ignored: the id
/// is allocated by SQLite (INTEGER PK AUTOINCREMENT) and returned.
async fn insert(&self, row: &RequirementRow) -> Result<i64, DbError>;
/// Partial update by ID. Returns `DbError::NotFound` if absent.
async fn update(&self, id: i64, params: &RequirementRowUpdate) -> Result<(), DbError>;
/// Delete by ID. Returns `DbError::NotFound` if absent.
async fn delete(&self, id: i64) -> Result<(), DbError>;
/// Fetch a single requirement by ID.
async fn get_by_id(&self, id: i64) -> Result<Option<RequirementRow>, DbError>;
/// List with filters + pagination. Returns `(rows, total_matching)`.
async fn list(&self, params: &ListRequirementsParams) -> Result<(Vec<RequirementRow>, u64), DbError>;
/// All requirements for a tag, ordered by `sort_seq ASC, priority DESC, created_at ASC`.
async fn list_by_tag(&self, tag: &str) -> Result<Vec<RequirementRow>, DbError>;
/// Distinct tags with per-status counts. Returns rows of `(tag, status, count)`.
async fn tag_status_counts(&self) -> Result<Vec<(String, String, i64)>, DbError>;
/// Atomically claim the next pending requirement for `tag`.
///
/// Single `UPDATE … WHERE id = (SELECT … LIMIT 1) RETURNING *` — SQLite's
/// single-writer guarantee makes this the entire idempotent allocator.
/// Records the executing session as `owner_session_id` + `owner_kind`
/// (`'conversation'` | `'terminal'`), set together to satisfy the table's
/// paired-NULL CHECK. Returns the claimed row, or `None` when the tag has
/// no pending requirements.
async fn claim_next(
&self,
tag: &str,
owner_session_id: i64,
owner_kind: &str,
lease_ms: i64,
now: TimestampMs,
) -> Result<Option<RequirementRow>, DbError>;
/// Renew the lease for a requirement currently claimed by `owner` (matched
/// against `owner_session_id`). Returns true if a row was renewed.
async fn renew_lease(&self, id: i64, owner: i64, lease_ms: i64, now: TimestampMs) -> Result<bool, DbError>;
/// Re-pend in_progress requirements whose lease expired and whose owning
/// session is no longer active. Each active entry is a `(owner_kind,
/// owner_session_id)` pair — both are matched together, because the
/// integer owner id is dual-domain (a conversation and a terminal can share
/// a number), so a kind-less match would wrongly treat an active `conv#5`
/// as keeping a stale `term#5` claim alive (spec §2.2). Returns the count reset.
async fn sweep_expired_leases(
&self,
active_sessions: &[(String, i64)],
now: TimestampMs,
) -> Result<u64, DbError>;
// ── AutoWork tag-level pause (Step 1) ──────────────────────────────
/// Pause a tag (lazily upserts the row). Idempotent: re-pausing updates the
/// reason / triggering requirement. After this, `claim_next(tag, …)` yields
/// `None` until `resume_tag`.
async fn pause_tag(
&self,
tag: &str,
reason: &str,
req_id: Option<i64>,
now: TimestampMs,
) -> Result<(), DbError>;
/// Resume a paused tag (clears the paused flag). No-op if the tag has no row
/// (absent = not paused).
async fn resume_tag(&self, tag: &str) -> Result<(), DbError>;
/// Whether `tag` is currently paused.
async fn is_tag_paused(&self, tag: &str) -> Result<bool, DbError>;
/// Full pause state for a tag, if a row exists (`None` = never paused).
async fn get_tag_state(&self, tag: &str) -> Result<Option<RequirementTagRow>, DbError>;
/// Revert a claim WITHOUT consuming an attempt — e.g. the inject was
/// rejected because the session was busy. Resets the row to `pending`,
/// decrements `attempt_count` (floored at 0), and clears the owner fields
/// (`owner_session_id` + `owner_kind`, paired). Guarded by
/// `status='in_progress' AND owner_session_id=owner`. Returns whether a
/// row was reverted. Distinct from the error re-pend path, which DOES keep
/// the consumed attempt.
async fn unclaim(&self, id: i64, owner: i64) -> Result<bool, DbError>;
}
@@ -0,0 +1,23 @@
use crate::error::DbError;
use crate::models::SystemSettings;
/// System settings data access abstraction.
///
/// The `system_settings` table holds a single row (id=1).
/// `get_settings` returns `None` if no row exists yet (caller uses defaults).
/// `upsert_settings` inserts or replaces the single row.
#[async_trait::async_trait]
pub trait ISettingsRepository: Send + Sync {
/// Returns the settings row, or `None` if no settings have been persisted.
async fn get_settings(&self) -> Result<Option<SystemSettings>, DbError>;
/// Inserts or replaces the single settings row.
async fn upsert_settings(
&self,
language: &str,
notification_enabled: bool,
cron_notification_enabled: bool,
command_queue_enabled: bool,
save_upload_to_workspace: bool,
) -> Result<SystemSettings, DbError>;
}
@@ -0,0 +1,10 @@
use crate::error::DbError;
use crate::models::{SkillTagRow, UpsertSkillTagParams};
/// CRUD for per-skill tag assignments (keyed by skill name).
#[async_trait::async_trait]
pub trait ISkillTagRepository: Send + Sync {
async fn get_all(&self) -> Result<Vec<SkillTagRow>, DbError>;
async fn upsert(&self, params: &UpsertSkillTagParams<'_>) -> Result<SkillTagRow, DbError>;
async fn delete(&self, skill_name: &str) -> Result<bool, DbError>;
}
@@ -0,0 +1,528 @@
//! SQLite-backed `acp_session` repository.
use nomifun_common::now_ms;
use serde_json::Value;
use sqlx::SqlitePool;
use crate::error::DbError;
use crate::models::AcpSessionRow;
use crate::repository::acp_session::{
CreateAcpSessionParams, IAcpSessionRepository, PersistedSessionState, SaveRuntimeStateParams,
};
#[derive(Clone, Debug)]
pub struct SqliteAcpSessionRepository {
pool: SqlitePool,
}
impl SqliteAcpSessionRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
fn is_unique_violation(err: &dyn sqlx::error::DatabaseError) -> bool {
err.code().is_some_and(|c| c == "2067" || c == "1555")
}
#[async_trait::async_trait]
impl IAcpSessionRepository for SqliteAcpSessionRepository {
async fn get(&self, conversation_id: i64) -> Result<Option<AcpSessionRow>, DbError> {
// `agent_id` is nullable in the schema (NULL = "no agent chosen yet").
// COALESCE it back to the empty-string sentinel so `AcpSessionRow.agent_id`
// stays a non-optional `String` for all downstream consumers.
let row = sqlx::query_as::<_, AcpSessionRow>(
"SELECT conversation_id, agent_backend, agent_source, \
COALESCE(agent_id, '') AS agent_id, session_id, session_status, \
session_config, last_active_at, suspended_at \
FROM acp_session WHERE conversation_id = ?",
)
.bind(conversation_id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
async fn create(&self, params: &CreateAcpSessionParams<'_>) -> Result<AcpSessionRow, DbError> {
let now = now_ms();
// Write NULL (not the empty-string sentinel) when no agent is chosen so
// the RESTRICT FK to agent_metadata is not evaluated against ''.
let agent_id: Option<&str> = Some(params.agent_id).filter(|s| !s.is_empty());
sqlx::query(
"INSERT INTO acp_session \
(conversation_id, agent_backend, agent_source, agent_id, \
session_id, session_status, session_config, last_active_at) \
VALUES (?, ?, ?, ?, NULL, 'idle', '{}', ?)",
)
.bind(params.conversation_id)
.bind(params.agent_backend)
.bind(params.agent_source)
.bind(agent_id)
.bind(now)
.execute(&self.pool)
.await
.map_err(|e| match &e {
sqlx::Error::Database(db_err) if is_unique_violation(db_err.as_ref()) => DbError::Conflict(format!(
"acp_session row for conversation '{}' already exists",
params.conversation_id
)),
_ => DbError::Query(e),
})?;
self.get(params.conversation_id).await?.ok_or_else(|| {
DbError::Init(format!(
"create did not produce acp_session row for '{}'",
params.conversation_id
))
})
}
async fn update_session_id(&self, conversation_id: i64, session_id: &str) -> Result<bool, DbError> {
let now = now_ms();
let result = sqlx::query("UPDATE acp_session SET session_id = ?, last_active_at = ? WHERE conversation_id = ?")
.bind(session_id)
.bind(now)
.bind(conversation_id)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
}
async fn clear_session_id(&self, conversation_id: i64) -> Result<bool, DbError> {
// Read-modify-write the JSON blob to drop the cached usage while
// leaving the user's mode/model/config selections intact (those are
// preferences, not context). Same RMW rationale as
// `save_runtime_state`: writes per conversation_id are serialised.
let raw: Option<String> =
sqlx::query_scalar("SELECT session_config FROM acp_session WHERE conversation_id = ?")
.bind(conversation_id)
.fetch_optional(&self.pool)
.await?;
let Some(raw) = raw else {
return Ok(false);
};
let mut parsed: Value = serde_json::from_str(&raw).unwrap_or_else(|_| Value::Object(Default::default()));
if let Some(runtime) = parsed
.as_object_mut()
.and_then(|obj| obj.get_mut("runtime"))
.and_then(Value::as_object_mut)
{
runtime.remove("context_usage");
}
let new_config =
serde_json::to_string(&parsed).map_err(|e| DbError::Init(format!("encode session_config: {e}")))?;
let now = now_ms();
let result = sqlx::query(
"UPDATE acp_session SET session_id = NULL, session_status = 'idle', \
session_config = ?, last_active_at = ? WHERE conversation_id = ?",
)
.bind(new_config)
.bind(now)
.bind(conversation_id)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
}
async fn delete(&self, conversation_id: i64) -> Result<bool, DbError> {
let result = sqlx::query("DELETE FROM acp_session WHERE conversation_id = ?")
.bind(conversation_id)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
}
async fn load_runtime_state(&self, conversation_id: i64) -> Result<Option<PersistedSessionState>, DbError> {
let raw: Option<String> =
sqlx::query_scalar("SELECT session_config FROM acp_session WHERE conversation_id = ?")
.bind(conversation_id)
.fetch_optional(&self.pool)
.await?;
let Some(raw) = raw else {
return Ok(None);
};
let parsed: Value =
serde_json::from_str(&raw).map_err(|e| DbError::Init(format!("invalid session_config JSON: {e}")))?;
let runtime = parsed.get("runtime");
let mut state = PersistedSessionState::default();
if let Some(rt) = runtime {
state.current_mode_id = rt.get("current_mode_id").and_then(Value::as_str).map(ToOwned::to_owned);
state.current_model_id = rt
.get("current_model_id")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
state.config_selections_json = rt.get("config_selections").map(serde_json::Value::to_string);
state.context_usage_json = rt.get("context_usage").map(serde_json::Value::to_string);
}
Ok(Some(state))
}
async fn save_runtime_state(
&self,
conversation_id: i64,
params: &SaveRuntimeStateParams<'_>,
) -> Result<bool, DbError> {
if params.is_empty() {
return Ok(true);
}
// Read-modify-write. The service layer serialises writes per
// conversation_id through a single consumer task, so a naive
// RMW is race-free for our callers.
let raw: Option<String> =
sqlx::query_scalar("SELECT session_config FROM acp_session WHERE conversation_id = ?")
.bind(conversation_id)
.fetch_optional(&self.pool)
.await?;
let Some(raw) = raw else {
return Ok(false);
};
let mut parsed: Value = serde_json::from_str(&raw).unwrap_or_else(|_| Value::Object(Default::default()));
let runtime = parsed
.as_object_mut()
.ok_or_else(|| DbError::Init("session_config is not a JSON object".into()))?
.entry("runtime")
.or_insert_with(|| Value::Object(Default::default()));
let runtime = runtime
.as_object_mut()
.ok_or_else(|| DbError::Init("session_config.runtime is not a JSON object".into()))?;
if let Some(outer) = params.current_mode_id {
match outer {
Some(v) => {
runtime.insert("current_mode_id".into(), Value::String(v.to_owned()));
}
None => {
runtime.remove("current_mode_id");
}
}
}
if let Some(outer) = params.current_model_id {
match outer {
Some(v) => {
runtime.insert("current_model_id".into(), Value::String(v.to_owned()));
}
None => {
runtime.remove("current_model_id");
}
}
}
if let Some(outer) = params.config_selections_json {
match outer {
Some(json) => {
let v: Value = serde_json::from_str(json)
.map_err(|e| DbError::Init(format!("invalid config_selections JSON: {e}")))?;
runtime.insert("config_selections".into(), v);
}
None => {
runtime.remove("config_selections");
}
}
}
if let Some(outer) = params.context_usage_json {
match outer {
Some(json) => {
let v: Value = serde_json::from_str(json)
.map_err(|e| DbError::Init(format!("invalid context_usage JSON: {e}")))?;
runtime.insert("context_usage".into(), v);
}
None => {
runtime.remove("context_usage");
}
}
}
let new_config =
serde_json::to_string(&parsed).map_err(|e| DbError::Init(format!("encode session_config: {e}")))?;
let now = now_ms();
let result = sqlx::query(
"UPDATE acp_session SET session_config = ?, last_active_at = ? \
WHERE conversation_id = ?",
)
.bind(new_config)
.bind(now)
.bind(conversation_id)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::init_database_memory;
async fn setup() -> (SqliteAcpSessionRepository, crate::Database) {
let db = init_database_memory().await.unwrap();
let repo = SqliteAcpSessionRepository::new(db.pool().clone());
(repo, db)
}
fn create_params(conversation_id: i64) -> CreateAcpSessionParams<'static> {
CreateAcpSessionParams {
conversation_id,
agent_backend: "claude",
agent_source: "builtin",
agent_id: "agent_builtin_claude",
}
}
/// Insert a conversation so the `acp_session.conversation_id` FK
/// (REFERENCES conversations(id) ON DELETE CASCADE) is satisfied before
/// `create()` inserts the session row. `system_default_user` is seeded by
/// init_database_memory, satisfying conversations.user_id FK; the
/// `agent_builtin_claude` referenced by `create_params` is likewise seeded,
/// satisfying the acp_session.agent_id FK. The explicit integer id is a valid
/// AUTOINCREMENT rowid.
async fn seed_conversation(pool: &SqlitePool, id: i64) {
sqlx::query(
"INSERT INTO conversations (id, user_id, name, type, status, created_at, updated_at) \
VALUES (?, 'system_default_user', 'c', 'normal', 'pending', 1, 1)",
)
.bind(id)
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn create_then_get_roundtrips() {
let (repo, _db) = setup().await;
seed_conversation(&repo.pool, 1).await;
let row = repo.create(&create_params(1)).await.unwrap();
assert_eq!(row.conversation_id, 1);
assert_eq!(row.agent_backend, "claude");
assert_eq!(row.session_id, None);
assert_eq!(row.session_status, "idle");
assert_eq!(row.session_config, "{}");
let fetched = repo.get(1).await.unwrap().unwrap();
assert_eq!(fetched.conversation_id, 1);
}
#[tokio::test]
async fn create_duplicate_returns_conflict() {
let (repo, _db) = setup().await;
seed_conversation(&repo.pool, 1).await;
repo.create(&create_params(1)).await.unwrap();
let err = repo.create(&create_params(1)).await.unwrap_err();
assert!(matches!(err, DbError::Conflict(_)));
}
#[tokio::test]
async fn update_session_id_flips_field() {
let (repo, _db) = setup().await;
seed_conversation(&repo.pool, 1).await;
repo.create(&create_params(1)).await.unwrap();
assert!(repo.update_session_id(1, "sess-abc").await.unwrap());
let fetched = repo.get(1).await.unwrap().unwrap();
assert_eq!(fetched.session_id.as_deref(), Some("sess-abc"));
assert!(fetched.last_active_at.is_some());
}
#[tokio::test]
async fn update_session_id_missing_row_returns_false() {
let (repo, _db) = setup().await;
assert!(!repo.update_session_id(999, "sid").await.unwrap());
}
#[tokio::test]
async fn clear_session_id_nulls_sid_and_drops_usage_keeps_prefs() {
let (repo, _db) = setup().await;
seed_conversation(&repo.pool, 1).await;
repo.create(&create_params(1)).await.unwrap();
repo.update_session_id(1, "sess-abc").await.unwrap();
repo.save_runtime_state(
1,
&SaveRuntimeStateParams {
current_mode_id: Some(Some("code")),
current_model_id: Some(Some("sonnet-4")),
context_usage_json: Some(Some(r#"{"used":123,"total":200}"#)),
..Default::default()
},
)
.await
.unwrap();
assert!(repo.clear_session_id(1).await.unwrap());
let row = repo.get(1).await.unwrap().unwrap();
assert_eq!(row.session_id, None, "session_id must be nulled");
assert_eq!(row.session_status, "idle");
let state = repo.load_runtime_state(1).await.unwrap().unwrap();
assert!(state.context_usage_json.is_none(), "cached usage must be dropped");
assert_eq!(state.current_mode_id.as_deref(), Some("code"), "mode pref kept");
assert_eq!(state.current_model_id.as_deref(), Some("sonnet-4"), "model pref kept");
}
#[tokio::test]
async fn clear_session_id_missing_row_returns_false() {
let (repo, _db) = setup().await;
assert!(!repo.clear_session_id(999).await.unwrap());
}
#[tokio::test]
async fn delete_removes_row() {
let (repo, _db) = setup().await;
seed_conversation(&repo.pool, 1).await;
repo.create(&create_params(1)).await.unwrap();
assert!(repo.delete(1).await.unwrap());
assert!(repo.get(1).await.unwrap().is_none());
assert!(!repo.delete(1).await.unwrap());
}
#[tokio::test]
async fn load_runtime_state_missing_row() {
let (repo, _db) = setup().await;
assert!(repo.load_runtime_state(999).await.unwrap().is_none());
}
#[tokio::test]
async fn load_runtime_state_empty_config_returns_defaults() {
let (repo, _db) = setup().await;
seed_conversation(&repo.pool, 1).await;
repo.create(&create_params(1)).await.unwrap();
let state = repo.load_runtime_state(1).await.unwrap().unwrap();
assert_eq!(state, PersistedSessionState::default());
}
#[tokio::test]
async fn save_runtime_state_writes_each_field() {
let (repo, _db) = setup().await;
seed_conversation(&repo.pool, 1).await;
repo.create(&create_params(1)).await.unwrap();
assert!(
repo.save_runtime_state(
1,
&SaveRuntimeStateParams {
current_mode_id: Some(Some("code")),
current_model_id: Some(Some("claude-sonnet-4")),
config_selections_json: Some(Some(r#"{"reasoning":"high"}"#)),
context_usage_json: Some(Some(r#"{"used":10,"total":100}"#)),
},
)
.await
.unwrap()
);
let state = repo.load_runtime_state(1).await.unwrap().unwrap();
assert_eq!(state.current_mode_id.as_deref(), Some("code"));
assert_eq!(state.current_model_id.as_deref(), Some("claude-sonnet-4"));
// The stored JSON should parse back to the same payload
// regardless of key order (serde_json::Map preserves insertion
// order but the caller shouldn't depend on it here).
let selections: Value = serde_json::from_str(state.config_selections_json.as_deref().unwrap()).unwrap();
assert_eq!(selections["reasoning"], "high");
let usage: Value = serde_json::from_str(state.context_usage_json.as_deref().unwrap()).unwrap();
assert_eq!(usage["used"], 10);
assert_eq!(usage["total"], 100);
}
#[tokio::test]
async fn save_runtime_state_partial_preserves_siblings() {
let (repo, _db) = setup().await;
seed_conversation(&repo.pool, 1).await;
repo.create(&create_params(1)).await.unwrap();
repo.save_runtime_state(
1,
&SaveRuntimeStateParams {
current_mode_id: Some(Some("code")),
current_model_id: Some(Some("sonnet-4")),
..Default::default()
},
)
.await
.unwrap();
// Later write only touches current_model_id.
repo.save_runtime_state(
1,
&SaveRuntimeStateParams {
current_model_id: Some(Some("opus-4")),
..Default::default()
},
)
.await
.unwrap();
let state = repo.load_runtime_state(1).await.unwrap().unwrap();
assert_eq!(
state.current_mode_id.as_deref(),
Some("code"),
"mode must survive the model-only write"
);
assert_eq!(state.current_model_id.as_deref(), Some("opus-4"));
}
#[tokio::test]
async fn save_runtime_state_some_none_clears_field() {
let (repo, _db) = setup().await;
seed_conversation(&repo.pool, 1).await;
repo.create(&create_params(1)).await.unwrap();
repo.save_runtime_state(
1,
&SaveRuntimeStateParams {
current_mode_id: Some(Some("code")),
..Default::default()
},
)
.await
.unwrap();
repo.save_runtime_state(
1,
&SaveRuntimeStateParams {
current_mode_id: Some(None),
..Default::default()
},
)
.await
.unwrap();
let state = repo.load_runtime_state(1).await.unwrap().unwrap();
assert!(state.current_mode_id.is_none());
}
#[tokio::test]
async fn save_runtime_state_empty_params_is_noop() {
let (repo, _db) = setup().await;
seed_conversation(&repo.pool, 1).await;
repo.create(&create_params(1)).await.unwrap();
assert!(
repo.save_runtime_state(1, &SaveRuntimeStateParams::default())
.await
.unwrap()
);
let state = repo.load_runtime_state(1).await.unwrap().unwrap();
assert_eq!(state, PersistedSessionState::default());
}
#[tokio::test]
async fn save_runtime_state_missing_row_returns_false() {
let (repo, _db) = setup().await;
let ok = repo
.save_runtime_state(
999,
&SaveRuntimeStateParams {
current_mode_id: Some(Some("x")),
..Default::default()
},
)
.await
.unwrap();
assert!(!ok);
}
}
@@ -0,0 +1,469 @@
//! SQLite-backed agent metadata repository.
use nomifun_common::now_ms;
use sqlx::SqlitePool;
use crate::error::DbError;
use crate::models::{AgentMetadataRow, UpdateAgentHandshakeParams, UpsertAgentMetadataParams};
use crate::repository::agent_metadata::IAgentMetadataRepository;
#[derive(Clone, Debug)]
pub struct SqliteAgentMetadataRepository {
pool: SqlitePool,
}
impl SqliteAgentMetadataRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait::async_trait]
impl IAgentMetadataRepository for SqliteAgentMetadataRepository {
async fn list_all(&self) -> Result<Vec<AgentMetadataRow>, DbError> {
let rows =
sqlx::query_as::<_, AgentMetadataRow>("SELECT * FROM agent_metadata ORDER BY sort_order ASC, name ASC")
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
async fn get(&self, id: &str) -> Result<Option<AgentMetadataRow>, DbError> {
let row = sqlx::query_as::<_, AgentMetadataRow>("SELECT * FROM agent_metadata WHERE id = ?")
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
async fn find_by_source_and_name(
&self,
agent_source: &str,
name: &str,
) -> Result<Option<AgentMetadataRow>, DbError> {
let row =
sqlx::query_as::<_, AgentMetadataRow>("SELECT * FROM agent_metadata WHERE agent_source = ? AND name = ?")
.bind(agent_source)
.bind(name)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
async fn find_builtin_by_backend(&self, backend: &str) -> Result<Option<AgentMetadataRow>, DbError> {
let row = sqlx::query_as::<_, AgentMetadataRow>(
"SELECT * FROM agent_metadata \
WHERE agent_source = 'builtin' AND backend = ? \
ORDER BY sort_order ASC, name ASC LIMIT 1",
)
.bind(backend)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
async fn upsert(&self, params: &UpsertAgentMetadataParams<'_>) -> Result<AgentMetadataRow, DbError> {
let now = now_ms();
sqlx::query(
"INSERT INTO agent_metadata \
(id, icon, name, name_i18n, description, description_i18n, \
backend, agent_type, agent_source, agent_source_info, \
enabled, command, args, env, native_skills_dirs, \
behavior_policy, yolo_id, \
agent_capabilities, auth_methods, config_options, \
available_modes, available_models, available_commands, \
sort_order, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
ON CONFLICT(id) DO UPDATE SET \
icon = excluded.icon, \
name = excluded.name, \
name_i18n = excluded.name_i18n, \
description = excluded.description, \
description_i18n = excluded.description_i18n, \
backend = excluded.backend, \
agent_type = excluded.agent_type, \
agent_source = excluded.agent_source, \
agent_source_info = excluded.agent_source_info, \
enabled = excluded.enabled, \
command = excluded.command, \
args = excluded.args, \
env = excluded.env, \
native_skills_dirs = excluded.native_skills_dirs, \
behavior_policy = excluded.behavior_policy, \
yolo_id = excluded.yolo_id, \
agent_capabilities = excluded.agent_capabilities, \
auth_methods = excluded.auth_methods, \
config_options = excluded.config_options, \
available_modes = excluded.available_modes, \
available_models = excluded.available_models, \
available_commands = excluded.available_commands, \
sort_order = excluded.sort_order, \
updated_at = excluded.updated_at",
)
.bind(params.id)
.bind(params.icon)
.bind(params.name)
.bind(params.name_i18n)
.bind(params.description)
.bind(params.description_i18n)
.bind(params.backend)
.bind(params.agent_type)
.bind(params.agent_source)
.bind(params.agent_source_info)
.bind(params.enabled)
.bind(params.command)
.bind(params.args)
.bind(params.env)
.bind(params.native_skills_dirs)
.bind(params.behavior_policy)
.bind(params.yolo_id)
.bind(params.agent_capabilities)
.bind(params.auth_methods)
.bind(params.config_options)
.bind(params.available_modes)
.bind(params.available_models)
.bind(params.available_commands)
.bind(params.sort_order)
.bind(now)
.bind(now)
.execute(&self.pool)
.await?;
let row = self
.get(params.id)
.await?
.ok_or_else(|| DbError::Init(format!("upsert did not produce row for id '{}'", params.id)))?;
Ok(row)
}
async fn apply_handshake(
&self,
id: &str,
params: &UpdateAgentHandshakeParams<'_>,
) -> Result<Option<AgentMetadataRow>, DbError> {
let Some(existing) = self.get(id).await? else {
return Ok(None);
};
let now = now_ms();
let agent_capabilities = params
.agent_capabilities
.map_or(existing.agent_capabilities, |v| v.map(String::from));
let auth_methods = params
.auth_methods
.map_or(existing.auth_methods, |v| v.map(String::from));
let config_options = params
.config_options
.map_or(existing.config_options, |v| v.map(String::from));
let available_modes = params
.available_modes
.map_or(existing.available_modes, |v| v.map(String::from));
let available_models = params
.available_models
.map_or(existing.available_models, |v| v.map(String::from));
let available_commands = params
.available_commands
.map_or(existing.available_commands, |v| v.map(String::from));
sqlx::query(
"UPDATE agent_metadata SET \
agent_capabilities = ?, \
auth_methods = ?, \
config_options = ?, \
available_modes = ?, \
available_models = ?, \
available_commands = ?, \
updated_at = ? \
WHERE id = ?",
)
.bind(&agent_capabilities)
.bind(&auth_methods)
.bind(&config_options)
.bind(&available_modes)
.bind(&available_models)
.bind(&available_commands)
.bind(now)
.bind(id)
.execute(&self.pool)
.await?;
self.get(id).await
}
async fn set_enabled(&self, id: &str, enabled: bool) -> Result<bool, DbError> {
let now = now_ms();
let result = sqlx::query("UPDATE agent_metadata SET enabled = ?, updated_at = ? WHERE id = ?")
.bind(enabled)
.bind(now)
.bind(id)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
}
async fn set_behavior_policy(
&self,
id: &str,
behavior_policy: &str,
) -> Result<Option<AgentMetadataRow>, DbError> {
if self.get(id).await?.is_none() {
return Ok(None);
}
let now = now_ms();
sqlx::query("UPDATE agent_metadata SET behavior_policy = ?, updated_at = ? WHERE id = ?")
.bind(behavior_policy)
.bind(now)
.bind(id)
.execute(&self.pool)
.await?;
self.get(id).await
}
async fn delete(&self, id: &str) -> Result<bool, DbError> {
let result = sqlx::query("DELETE FROM agent_metadata WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::init_database_memory;
async fn setup() -> (SqliteAgentMetadataRepository, crate::Database) {
let db = init_database_memory().await.unwrap();
let repo = SqliteAgentMetadataRepository::new(db.pool().clone());
(repo, db)
}
fn custom_params<'a>(id: &'a str, name: &'a str) -> UpsertAgentMetadataParams<'a> {
UpsertAgentMetadataParams {
id,
icon: None,
name,
name_i18n: None,
description: Some("a custom agent"),
description_i18n: None,
backend: Some("claude"),
agent_type: "acp",
agent_source: "custom",
agent_source_info: Some(r#"{"binary_name":"claude"}"#),
enabled: true,
command: Some("claude"),
args: Some("[]"),
env: Some("[]"),
native_skills_dirs: Some(r#"[".claude/skills"]"#),
behavior_policy: Some(r#"{"supports_side_question":true}"#),
yolo_id: Some("bypassPermissions"),
agent_capabilities: None,
auth_methods: None,
config_options: None,
available_modes: None,
available_models: None,
available_commands: None,
sort_order: 1100,
}
}
#[tokio::test]
async fn seed_rows_populated_after_migrations() {
let (repo, _db) = setup().await;
let rows = repo.list_all().await.unwrap();
// 17 ACP vendors + 2 non-ACP builtins + 1 internal = 20.
assert_eq!(rows.len(), 20);
assert!(
rows.iter()
.any(|r| r.name == "Claude Code" && r.agent_source == "builtin")
);
assert!(rows.iter().any(|r| r.name == "Nomi" && r.agent_source == "internal"));
// Nanobot and OpenClaw are builtin (not internal).
assert!(rows.iter().any(|r| r.name == "Nanobot" && r.agent_source == "builtin"));
assert!(rows.iter().any(|r| r.name == "OpenClaw" && r.agent_source == "builtin"));
}
#[tokio::test]
async fn builtins_use_current_official_cli_names_after_migrations() {
let (repo, _db) = setup().await;
let cursor = repo.get("agent_builtin_cursor").await.unwrap().expect("seeded cursor row");
assert_eq!(cursor.agent_source_info.as_deref(), Some(r#"{"binary_name":"agent"}"#));
assert_eq!(cursor.command.as_deref(), Some("agent"));
assert_eq!(cursor.args.as_deref(), Some(r#"["acp"]"#));
let kiro = repo.get("agent_builtin_kiro").await.unwrap().expect("seeded kiro row");
assert_eq!(kiro.agent_source_info.as_deref(), Some(r#"{"binary_name":"kiro-cli"}"#));
assert_eq!(kiro.command.as_deref(), Some("kiro-cli"));
assert_eq!(kiro.args.as_deref(), Some(r#"["acp"]"#));
}
#[tokio::test]
async fn find_by_source_and_name_hits_seed_row() {
let (repo, _db) = setup().await;
let row = repo
.find_by_source_and_name("builtin", "Claude Code")
.await
.unwrap()
.expect("seeded claude row");
assert_eq!(row.backend.as_deref(), Some("claude"));
assert_eq!(row.agent_type, "acp");
}
#[tokio::test]
async fn seed_rows_include_icon_backfill() {
let (repo, _db) = setup().await;
let claude = repo.get("agent_builtin_claude").await.unwrap().expect("seeded claude row");
assert_eq!(claude.icon.as_deref(), Some("/api/assets/logos/ai-major/claude.svg"));
let nomi = repo.get("agent_builtin_nomi").await.unwrap().expect("seeded nomi row");
assert_eq!(nomi.icon.as_deref(), Some("/api/assets/logos/brand/nomi.svg"));
let kiro = repo.get("agent_builtin_kiro").await.unwrap().expect("seeded kiro row");
assert!(kiro.icon.is_none());
}
#[tokio::test]
async fn upsert_inserts_then_updates() {
let (repo, _db) = setup().await;
let mut p = custom_params("custom-0001", "my-claude");
let first = repo.upsert(&p).await.unwrap();
assert_eq!(first.name, "my-claude");
assert!(first.enabled);
p.description = Some("updated");
p.enabled = false;
let second = repo.upsert(&p).await.unwrap();
assert_eq!(second.description.as_deref(), Some("updated"));
assert!(!second.enabled);
// No duplicate row introduced.
let matches: Vec<_> = repo
.list_all()
.await
.unwrap()
.into_iter()
.filter(|r| r.id == "custom-0001")
.collect();
assert_eq!(matches.len(), 1);
}
#[tokio::test]
async fn apply_handshake_updates_only_specified_fields() {
let (repo, _db) = setup().await;
let updated = repo
.apply_handshake(
"agent_builtin_claude",
&UpdateAgentHandshakeParams {
agent_capabilities: Some(Some(r#"{"loadSession":true}"#)),
auth_methods: Some(Some(r#"[{"id":"oauth"}]"#)),
..Default::default()
},
)
.await
.unwrap()
.expect("claude row exists");
assert_eq!(updated.agent_capabilities.as_deref(), Some(r#"{"loadSession":true}"#));
assert_eq!(updated.auth_methods.as_deref(), Some(r#"[{"id":"oauth"}]"#));
assert!(updated.config_options.is_none());
}
#[tokio::test]
async fn apply_handshake_can_clear_to_null() {
let (repo, _db) = setup().await;
repo.apply_handshake(
"agent_builtin_claude",
&UpdateAgentHandshakeParams {
agent_capabilities: Some(Some(r#"{"x":1}"#)),
..Default::default()
},
)
.await
.unwrap();
let cleared = repo
.apply_handshake(
"agent_builtin_claude",
&UpdateAgentHandshakeParams {
agent_capabilities: Some(None),
..Default::default()
},
)
.await
.unwrap()
.unwrap();
assert!(cleared.agent_capabilities.is_none());
}
#[tokio::test]
async fn apply_handshake_missing_row_returns_none() {
let (repo, _db) = setup().await;
let res = repo
.apply_handshake(
"does-not-exist",
&UpdateAgentHandshakeParams {
agent_capabilities: Some(Some("{}")),
..Default::default()
},
)
.await
.unwrap();
assert!(res.is_none());
}
#[tokio::test]
async fn set_enabled_toggles_flag() {
let (repo, _db) = setup().await;
assert!(repo.set_enabled("agent_builtin_claude", false).await.unwrap());
let row = repo.get("agent_builtin_claude").await.unwrap().unwrap();
assert!(!row.enabled);
assert!(!repo.set_enabled("missing", true).await.unwrap());
}
#[tokio::test]
async fn set_behavior_policy_overwrites_column_and_misses_unknown_row() {
let (repo, _db) = setup().await;
let updated = repo
.set_behavior_policy("agent_builtin_opencode", r#"{"supports_team":true}"#)
.await
.unwrap()
.expect("opencode row exists");
assert_eq!(updated.behavior_policy.as_deref(), Some(r#"{"supports_team":true}"#));
// Re-read confirms persistence.
let row = repo.get("agent_builtin_opencode").await.unwrap().unwrap();
assert_eq!(row.behavior_policy.as_deref(), Some(r#"{"supports_team":true}"#));
// Unknown id is a no-op returning None.
assert!(repo.set_behavior_policy("missing", "{}").await.unwrap().is_none());
}
#[tokio::test]
async fn delete_removes_row() {
let (repo, _db) = setup().await;
let p = custom_params("custom-0002", "throwaway");
repo.upsert(&p).await.unwrap();
assert!(repo.delete("custom-0002").await.unwrap());
assert!(repo.get("custom-0002").await.unwrap().is_none());
assert!(!repo.delete("custom-0002").await.unwrap());
}
#[tokio::test]
async fn same_source_same_name_allowed_with_different_ids() {
let (repo, _db) = setup().await;
let p1 = custom_params("custom-a", "dup");
let p2 = custom_params("custom-b", "dup");
repo.upsert(&p1).await.unwrap();
repo.upsert(&p2).await.unwrap();
let all = repo.list_all().await.unwrap();
let dup_count = all
.iter()
.filter(|r| r.name == "dup" && r.agent_source == "custom")
.count();
assert_eq!(
dup_count, 2,
"both rows should coexist after dropping UNIQUE(agent_source,name)"
);
}
}
@@ -0,0 +1,795 @@
//! SQLite-backed assistant repositories.
use nomifun_common::{TimestampMs, now_ms};
use sqlx::SqlitePool;
use crate::error::DbError;
use crate::models::{
AssistantOverrideRow, AssistantRow, AssistantTagRow, CreateAssistantParams, CreateAssistantTagParams,
UpdateAssistantParams, UpdateAssistantTagParams, UpsertOverrideParams,
};
use crate::repository::assistant::{IAssistantOverrideRepository, IAssistantRepository, IAssistantTagRepository};
/// SQLite-backed implementation of [`IAssistantRepository`].
#[derive(Clone, Debug)]
pub struct SqliteAssistantRepository {
pool: SqlitePool,
}
impl SqliteAssistantRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
fn is_unique_violation(err: &dyn sqlx::error::DatabaseError) -> bool {
err.code().is_some_and(|c| c == "2067" || c == "1555")
}
#[async_trait::async_trait]
impl IAssistantRepository for SqliteAssistantRepository {
async fn list(&self) -> Result<Vec<AssistantRow>, DbError> {
let rows = sqlx::query_as::<_, AssistantRow>("SELECT * FROM assistants ORDER BY updated_at DESC")
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
async fn get(&self, id: &str) -> Result<Option<AssistantRow>, DbError> {
let row = sqlx::query_as::<_, AssistantRow>("SELECT * FROM assistants WHERE id = ?")
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
async fn create(&self, params: &CreateAssistantParams<'_>) -> Result<AssistantRow, DbError> {
let now = now_ms();
sqlx::query(
"INSERT INTO assistants \
(id, name, description, avatar, preset_agent_type, enabled_skills, \
custom_skill_names, disabled_builtin_skills, prompts, models, \
name_i18n, description_i18n, prompts_i18n, audience_tags, scenario_tags, \
created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(params.id)
.bind(params.name)
.bind(params.description)
.bind(params.avatar)
.bind(params.preset_agent_type)
.bind(params.enabled_skills)
.bind(params.custom_skill_names)
.bind(params.disabled_builtin_skills)
.bind(params.prompts)
.bind(params.models)
.bind(params.name_i18n)
.bind(params.description_i18n)
.bind(params.prompts_i18n)
.bind(params.audience_tags)
.bind(params.scenario_tags)
.bind(now)
.bind(now)
.execute(&self.pool)
.await
.map_err(|e| match &e {
sqlx::Error::Database(db_err) if is_unique_violation(db_err.as_ref()) => {
DbError::Conflict(format!("Assistant with id '{}' already exists", params.id))
}
_ => DbError::Query(e),
})?;
Ok(AssistantRow {
id: params.id.to_string(),
name: params.name.to_string(),
description: params.description.map(String::from),
avatar: params.avatar.map(String::from),
preset_agent_type: params.preset_agent_type.to_string(),
enabled_skills: params.enabled_skills.map(String::from),
custom_skill_names: params.custom_skill_names.map(String::from),
disabled_builtin_skills: params.disabled_builtin_skills.map(String::from),
prompts: params.prompts.map(String::from),
models: params.models.map(String::from),
name_i18n: params.name_i18n.map(String::from),
description_i18n: params.description_i18n.map(String::from),
prompts_i18n: params.prompts_i18n.map(String::from),
audience_tags: params.audience_tags.map(String::from),
scenario_tags: params.scenario_tags.map(String::from),
created_at: now,
updated_at: now,
})
}
async fn update(&self, id: &str, params: &UpdateAssistantParams<'_>) -> Result<Option<AssistantRow>, DbError> {
let Some(existing) = self.get(id).await? else {
return Ok(None);
};
let merged = merge_update(existing, params);
sqlx::query(
"UPDATE assistants SET \
name = ?, description = ?, avatar = ?, preset_agent_type = ?, \
enabled_skills = ?, custom_skill_names = ?, disabled_builtin_skills = ?, \
prompts = ?, models = ?, name_i18n = ?, description_i18n = ?, \
prompts_i18n = ?, audience_tags = ?, scenario_tags = ?, updated_at = ? \
WHERE id = ?",
)
.bind(&merged.name)
.bind(&merged.description)
.bind(&merged.avatar)
.bind(&merged.preset_agent_type)
.bind(&merged.enabled_skills)
.bind(&merged.custom_skill_names)
.bind(&merged.disabled_builtin_skills)
.bind(&merged.prompts)
.bind(&merged.models)
.bind(&merged.name_i18n)
.bind(&merged.description_i18n)
.bind(&merged.prompts_i18n)
.bind(&merged.audience_tags)
.bind(&merged.scenario_tags)
.bind(merged.updated_at)
.bind(id)
.execute(&self.pool)
.await?;
Ok(Some(merged))
}
async fn delete(&self, id: &str) -> Result<bool, DbError> {
let result = sqlx::query("DELETE FROM assistants WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
}
async fn upsert(&self, params: &CreateAssistantParams<'_>) -> Result<AssistantRow, DbError> {
let now = now_ms();
sqlx::query(
"INSERT INTO assistants \
(id, name, description, avatar, preset_agent_type, enabled_skills, \
custom_skill_names, disabled_builtin_skills, prompts, models, \
name_i18n, description_i18n, prompts_i18n, audience_tags, scenario_tags, \
created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
ON CONFLICT(id) DO UPDATE SET \
name = excluded.name, \
description = excluded.description, \
avatar = excluded.avatar, \
preset_agent_type = excluded.preset_agent_type, \
enabled_skills = excluded.enabled_skills, \
custom_skill_names = excluded.custom_skill_names, \
disabled_builtin_skills = excluded.disabled_builtin_skills, \
prompts = excluded.prompts, \
models = excluded.models, \
name_i18n = excluded.name_i18n, \
description_i18n = excluded.description_i18n, \
prompts_i18n = excluded.prompts_i18n, \
audience_tags = excluded.audience_tags, \
scenario_tags = excluded.scenario_tags, \
updated_at = excluded.updated_at",
)
.bind(params.id)
.bind(params.name)
.bind(params.description)
.bind(params.avatar)
.bind(params.preset_agent_type)
.bind(params.enabled_skills)
.bind(params.custom_skill_names)
.bind(params.disabled_builtin_skills)
.bind(params.prompts)
.bind(params.models)
.bind(params.name_i18n)
.bind(params.description_i18n)
.bind(params.prompts_i18n)
.bind(params.audience_tags)
.bind(params.scenario_tags)
.bind(now)
.bind(now)
.execute(&self.pool)
.await?;
let row = self
.get(params.id)
.await?
.ok_or_else(|| DbError::Init(format!("upsert did not produce row for id '{}'", params.id)))?;
Ok(row)
}
}
fn merge_update(existing: AssistantRow, params: &UpdateAssistantParams<'_>) -> AssistantRow {
let now = now_ms();
AssistantRow {
id: existing.id,
name: params.name.map(String::from).unwrap_or(existing.name),
description: params.description.map_or(existing.description, |v| v.map(String::from)),
avatar: params.avatar.map_or(existing.avatar, |v| v.map(String::from)),
preset_agent_type: params
.preset_agent_type
.map(String::from)
.unwrap_or(existing.preset_agent_type),
enabled_skills: params
.enabled_skills
.map_or(existing.enabled_skills, |v| v.map(String::from)),
custom_skill_names: params
.custom_skill_names
.map_or(existing.custom_skill_names, |v| v.map(String::from)),
disabled_builtin_skills: params
.disabled_builtin_skills
.map_or(existing.disabled_builtin_skills, |v| v.map(String::from)),
prompts: params.prompts.map_or(existing.prompts, |v| v.map(String::from)),
models: params.models.map_or(existing.models, |v| v.map(String::from)),
name_i18n: params.name_i18n.map_or(existing.name_i18n, |v| v.map(String::from)),
description_i18n: params
.description_i18n
.map_or(existing.description_i18n, |v| v.map(String::from)),
prompts_i18n: params
.prompts_i18n
.map_or(existing.prompts_i18n, |v| v.map(String::from)),
audience_tags: params.audience_tags.map_or(existing.audience_tags, |v| v.map(String::from)),
scenario_tags: params.scenario_tags.map_or(existing.scenario_tags, |v| v.map(String::from)),
created_at: existing.created_at,
updated_at: now,
}
}
/// SQLite-backed implementation of [`IAssistantOverrideRepository`].
#[derive(Clone, Debug)]
pub struct SqliteAssistantOverrideRepository {
pool: SqlitePool,
}
impl SqliteAssistantOverrideRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait::async_trait]
impl IAssistantOverrideRepository for SqliteAssistantOverrideRepository {
async fn get(&self, assistant_id: &str) -> Result<Option<AssistantOverrideRow>, DbError> {
let row = sqlx::query_as::<_, AssistantOverrideRow>("SELECT * FROM assistant_overrides WHERE assistant_id = ?")
.bind(assistant_id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
async fn get_all(&self) -> Result<Vec<AssistantOverrideRow>, DbError> {
let rows = sqlx::query_as::<_, AssistantOverrideRow>("SELECT * FROM assistant_overrides")
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
async fn upsert(&self, params: &UpsertOverrideParams<'_>) -> Result<AssistantOverrideRow, DbError> {
let now = now_ms();
let last_used_at: Option<TimestampMs> = params.last_used_at;
// `preset_agent_type` has three-way semantics in the params struct
// (see `UpsertOverrideParams`). At the SQL layer we flatten it into a
// `(write?, value)` pair: on CONFLICT, if the caller did not specify
// a new value, `COALESCE(new_flag, 0)` keeps the existing column.
let (pat_write, pat_value): (bool, Option<&str>) = match params.preset_agent_type {
Some(v) => (true, v),
None => (false, None),
};
sqlx::query(
"INSERT INTO assistant_overrides \
(assistant_id, enabled, sort_order, last_used_at, preset_agent_type, updated_at) \
VALUES (?, ?, ?, ?, ?, ?) \
ON CONFLICT(assistant_id) DO UPDATE SET \
enabled = excluded.enabled, \
sort_order = excluded.sort_order, \
last_used_at = COALESCE(excluded.last_used_at, assistant_overrides.last_used_at), \
preset_agent_type = CASE WHEN ? THEN ? ELSE assistant_overrides.preset_agent_type END, \
updated_at = excluded.updated_at",
)
.bind(params.assistant_id)
.bind(params.enabled)
.bind(params.sort_order)
.bind(last_used_at)
.bind(pat_value)
.bind(now)
.bind(pat_write)
.bind(pat_value)
.execute(&self.pool)
.await?;
let row = self.get(params.assistant_id).await?.ok_or_else(|| {
DbError::Init(format!(
"upsert did not produce override row for id '{}'",
params.assistant_id
))
})?;
Ok(row)
}
async fn delete(&self, assistant_id: &str) -> Result<bool, DbError> {
let result = sqlx::query("DELETE FROM assistant_overrides WHERE assistant_id = ?")
.bind(assistant_id)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
}
async fn delete_orphans(&self, valid_ids: &[&str]) -> Result<u64, DbError> {
if valid_ids.is_empty() {
let result = sqlx::query("DELETE FROM assistant_overrides")
.execute(&self.pool)
.await?;
return Ok(result.rows_affected());
}
let placeholders = std::iter::repeat_n("?", valid_ids.len()).collect::<Vec<_>>().join(",");
let sql = format!("DELETE FROM assistant_overrides WHERE assistant_id NOT IN ({placeholders})");
let mut q = sqlx::query(&sql);
for id in valid_ids {
q = q.bind(*id);
}
let result = q.execute(&self.pool).await?;
Ok(result.rows_affected())
}
}
/// SQLite-backed implementation of [`IAssistantTagRepository`].
#[derive(Clone, Debug)]
pub struct SqliteAssistantTagRepository {
pool: SqlitePool,
}
impl SqliteAssistantTagRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait::async_trait]
impl IAssistantTagRepository for SqliteAssistantTagRepository {
async fn list(&self) -> Result<Vec<AssistantTagRow>, DbError> {
let rows = sqlx::query_as::<_, AssistantTagRow>(
"SELECT * FROM assistant_tags ORDER BY dimension ASC, sort_order ASC, created_at ASC",
)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
async fn get(&self, key: &str) -> Result<Option<AssistantTagRow>, DbError> {
let row = sqlx::query_as::<_, AssistantTagRow>("SELECT * FROM assistant_tags WHERE key = ?")
.bind(key)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
async fn create(&self, params: &CreateAssistantTagParams<'_>) -> Result<AssistantTagRow, DbError> {
let now = now_ms();
sqlx::query(
"INSERT INTO assistant_tags (key, dimension, label, sort_order, created_at) VALUES (?, ?, ?, ?, ?)",
)
.bind(params.key)
.bind(params.dimension)
.bind(params.label)
.bind(params.sort_order)
.bind(now)
.execute(&self.pool)
.await
.map_err(|e| match &e {
sqlx::Error::Database(db_err) if is_unique_violation(db_err.as_ref()) => {
DbError::Conflict(format!("Tag with key '{}' already exists", params.key))
}
_ => DbError::Query(e),
})?;
Ok(AssistantTagRow {
key: params.key.to_string(),
dimension: params.dimension.to_string(),
label: params.label.to_string(),
sort_order: params.sort_order,
created_at: now,
})
}
async fn update(&self, key: &str, params: &UpdateAssistantTagParams<'_>) -> Result<Option<AssistantTagRow>, DbError> {
let Some(existing) = self.get(key).await? else {
return Ok(None);
};
let label = params.label.unwrap_or(&existing.label);
let sort_order = params.sort_order.unwrap_or(existing.sort_order);
sqlx::query("UPDATE assistant_tags SET label = ?, sort_order = ? WHERE key = ?")
.bind(label)
.bind(sort_order)
.bind(key)
.execute(&self.pool)
.await?;
Ok(Some(AssistantTagRow {
key: existing.key,
dimension: existing.dimension,
label: label.to_string(),
sort_order,
created_at: existing.created_at,
}))
}
async fn delete(&self, key: &str) -> Result<bool, DbError> {
let result = sqlx::query("DELETE FROM assistant_tags WHERE key = ?")
.bind(key)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
}
}
// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::init_database_memory;
async fn setup() -> (
SqliteAssistantRepository,
SqliteAssistantOverrideRepository,
crate::Database,
) {
let db = init_database_memory().await.unwrap();
let a = SqliteAssistantRepository::new(db.pool().clone());
let o = SqliteAssistantOverrideRepository::new(db.pool().clone());
(a, o, db)
}
fn params<'a>(id: &'a str, name: &'a str) -> CreateAssistantParams<'a> {
CreateAssistantParams {
id,
name,
description: Some("desc"),
avatar: None,
preset_agent_type: "gemini",
enabled_skills: Some(r#"["skill-a"]"#),
custom_skill_names: None,
disabled_builtin_skills: None,
prompts: Some(r#"["hello"]"#),
models: None,
name_i18n: Some(r#"{"zh-CN":"助手"}"#),
description_i18n: None,
prompts_i18n: None,
audience_tags: Some(r#"["office"]"#),
scenario_tags: None,
}
}
#[tokio::test]
async fn assistant_list_empty() {
let (a, _o, _db) = setup().await;
assert!(a.list().await.unwrap().is_empty());
}
#[tokio::test]
async fn assistant_create_then_get() {
let (a, _o, _db) = setup().await;
let row = a.create(&params("u1", "User One")).await.unwrap();
assert_eq!(row.id, "u1");
assert_eq!(row.name, "User One");
assert_eq!(row.preset_agent_type, "gemini");
assert_eq!(row.enabled_skills.as_deref(), Some(r#"["skill-a"]"#));
assert!(row.created_at > 0);
assert_eq!(row.created_at, row.updated_at);
let fetched = a.get("u1").await.unwrap().unwrap();
assert_eq!(fetched.name, "User One");
}
#[tokio::test]
async fn assistant_tags_round_trip_and_partial_update() {
let (a, _o, _db) = setup().await;
a.create(&params("u1", "Tagged")).await.unwrap();
let got = a.get("u1").await.unwrap().unwrap();
assert_eq!(got.audience_tags.as_deref(), Some(r#"["office"]"#));
assert!(got.scenario_tags.is_none());
// Setting Some(Some(..)) writes; omitting (None) keeps prior value.
let upd = UpdateAssistantParams {
scenario_tags: Some(Some(r#"["document"]"#)),
..Default::default()
};
let updated = a.update("u1", &upd).await.unwrap().unwrap();
assert_eq!(updated.audience_tags.as_deref(), Some(r#"["office"]"#)); // preserved
assert_eq!(updated.scenario_tags.as_deref(), Some(r#"["document"]"#)); // written
}
#[tokio::test]
async fn assistant_create_duplicate_id_returns_conflict() {
let (a, _o, _db) = setup().await;
a.create(&params("u1", "A")).await.unwrap();
let err = a.create(&params("u1", "B")).await.unwrap_err();
assert!(matches!(err, DbError::Conflict(_)));
}
#[tokio::test]
async fn assistant_get_missing_returns_none() {
let (a, _o, _db) = setup().await;
assert!(a.get("nope").await.unwrap().is_none());
}
#[tokio::test]
async fn assistant_list_orders_by_updated_at_desc() {
let (a, _o, _db) = setup().await;
a.create(&params("u1", "first")).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
a.create(&params("u2", "second")).await.unwrap();
let list = a.list().await.unwrap();
assert_eq!(list.len(), 2);
assert_eq!(list[0].id, "u2");
assert_eq!(list[1].id, "u1");
}
#[tokio::test]
async fn assistant_update_partial_keeps_other_fields() {
let (a, _o, _db) = setup().await;
a.create(&params("u1", "original")).await.unwrap();
let upd = UpdateAssistantParams {
name: Some("renamed"),
..Default::default()
};
let updated = a.update("u1", &upd).await.unwrap().unwrap();
assert_eq!(updated.name, "renamed");
assert_eq!(updated.preset_agent_type, "gemini");
assert_eq!(updated.description.as_deref(), Some("desc"));
assert_eq!(updated.enabled_skills.as_deref(), Some(r#"["skill-a"]"#));
assert!(updated.updated_at >= updated.created_at);
}
#[tokio::test]
async fn assistant_update_clears_nullable_with_some_none() {
let (a, _o, _db) = setup().await;
a.create(&params("u1", "has-desc")).await.unwrap();
let upd = UpdateAssistantParams {
description: Some(None),
..Default::default()
};
let updated = a.update("u1", &upd).await.unwrap().unwrap();
assert!(updated.description.is_none());
}
#[tokio::test]
async fn assistant_update_nonexistent_returns_none() {
let (a, _o, _db) = setup().await;
let res = a
.update(
"nope",
&UpdateAssistantParams {
name: Some("x"),
..Default::default()
},
)
.await
.unwrap();
assert!(res.is_none());
}
#[tokio::test]
async fn assistant_delete_existing_returns_true() {
let (a, _o, _db) = setup().await;
a.create(&params("u1", "x")).await.unwrap();
assert!(a.delete("u1").await.unwrap());
assert!(a.get("u1").await.unwrap().is_none());
}
#[tokio::test]
async fn assistant_delete_missing_returns_false() {
let (a, _o, _db) = setup().await;
assert!(!a.delete("nope").await.unwrap());
}
#[tokio::test]
async fn assistant_upsert_inserts_then_updates() {
let (a, _o, _db) = setup().await;
let first = a.upsert(&params("u1", "first")).await.unwrap();
assert_eq!(first.name, "first");
let mut p = params("u1", "second");
p.preset_agent_type = "claude";
let second = a.upsert(&p).await.unwrap();
assert_eq!(second.name, "second");
assert_eq!(second.preset_agent_type, "claude");
let list = a.list().await.unwrap();
assert_eq!(list.len(), 1);
}
#[tokio::test]
async fn override_get_missing_returns_none() {
let (_a, o, _db) = setup().await;
assert!(o.get("u1").await.unwrap().is_none());
}
#[tokio::test]
async fn override_upsert_inserts_row() {
let (_a, o, _db) = setup().await;
let row = o
.upsert(&UpsertOverrideParams {
assistant_id: "u1",
enabled: false,
sort_order: 5,
last_used_at: Some(1000),
..Default::default()
})
.await
.unwrap();
assert_eq!(row.assistant_id, "u1");
assert!(!row.enabled);
assert_eq!(row.sort_order, 5);
assert_eq!(row.last_used_at, Some(1000));
}
#[tokio::test]
async fn override_upsert_updates_existing() {
let (_a, o, _db) = setup().await;
o.upsert(&UpsertOverrideParams {
assistant_id: "u1",
enabled: true,
sort_order: 0,
last_used_at: Some(1000),
..Default::default()
})
.await
.unwrap();
let updated = o
.upsert(&UpsertOverrideParams {
assistant_id: "u1",
enabled: false,
sort_order: 3,
last_used_at: None,
..Default::default()
})
.await
.unwrap();
assert!(!updated.enabled);
assert_eq!(updated.sort_order, 3);
// last_used_at None does not overwrite previous value (COALESCE)
assert_eq!(updated.last_used_at, Some(1000));
}
#[tokio::test]
async fn override_get_all_returns_rows() {
let (_a, o, _db) = setup().await;
o.upsert(&UpsertOverrideParams {
assistant_id: "u1",
enabled: true,
sort_order: 0,
last_used_at: None,
..Default::default()
})
.await
.unwrap();
o.upsert(&UpsertOverrideParams {
assistant_id: "u2",
enabled: false,
sort_order: 1,
last_used_at: None,
..Default::default()
})
.await
.unwrap();
let all = o.get_all().await.unwrap();
assert_eq!(all.len(), 2);
}
#[tokio::test]
async fn override_delete() {
let (_a, o, _db) = setup().await;
o.upsert(&UpsertOverrideParams {
assistant_id: "u1",
enabled: true,
sort_order: 0,
last_used_at: None,
..Default::default()
})
.await
.unwrap();
assert!(o.delete("u1").await.unwrap());
assert!(!o.delete("u1").await.unwrap());
}
#[tokio::test]
async fn override_delete_orphans_removes_only_absent() {
let (_a, o, _db) = setup().await;
for id in ["a", "b", "c"] {
o.upsert(&UpsertOverrideParams {
assistant_id: id,
enabled: true,
sort_order: 0,
last_used_at: None,
..Default::default()
})
.await
.unwrap();
}
let removed = o.delete_orphans(&["a", "c"]).await.unwrap();
assert_eq!(removed, 1);
let remaining: Vec<String> = o.get_all().await.unwrap().into_iter().map(|r| r.assistant_id).collect();
assert!(remaining.contains(&"a".to_string()));
assert!(remaining.contains(&"c".to_string()));
assert!(!remaining.contains(&"b".to_string()));
}
#[tokio::test]
async fn override_delete_orphans_empty_valid_ids_clears_table() {
let (_a, o, _db) = setup().await;
o.upsert(&UpsertOverrideParams {
assistant_id: "a",
enabled: true,
sort_order: 0,
last_used_at: None,
..Default::default()
})
.await
.unwrap();
let removed = o.delete_orphans(&[]).await.unwrap();
assert_eq!(removed, 1);
assert!(o.get_all().await.unwrap().is_empty());
}
#[tokio::test]
async fn tag_repo_create_list_update_delete() {
let db = init_database_memory().await.unwrap();
let r = SqliteAssistantTagRepository::new(db.pool().clone());
r.create(&CreateAssistantTagParams { key: "utag-1", dimension: "audience", label: "营销", sort_order: 3 })
.await
.unwrap();
let all = r.list().await.unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].label, "营销");
let updated = r
.update("utag-1", &UpdateAssistantTagParams { label: Some("市场营销"), sort_order: None })
.await
.unwrap()
.unwrap();
assert_eq!(updated.label, "市场营销");
assert_eq!(updated.sort_order, 3); // preserved
assert!(r.delete("utag-1").await.unwrap());
assert!(r.list().await.unwrap().is_empty());
}
#[tokio::test]
async fn tag_repo_update_missing_returns_none() {
let db = init_database_memory().await.unwrap();
let r = SqliteAssistantTagRepository::new(db.pool().clone());
let res = r
.update("nope", &UpdateAssistantTagParams { label: Some("x"), sort_order: None })
.await
.unwrap();
assert!(res.is_none());
}
#[tokio::test]
async fn tag_repo_delete_missing_returns_false() {
let db = init_database_memory().await.unwrap();
let r = SqliteAssistantTagRepository::new(db.pool().clone());
assert!(!r.delete("nope").await.unwrap());
}
#[tokio::test]
async fn tag_repo_duplicate_key_conflicts() {
let db = init_database_memory().await.unwrap();
let r = SqliteAssistantTagRepository::new(db.pool().clone());
let p = CreateAssistantTagParams { key: "k", dimension: "scenario", label: "A", sort_order: 0 };
r.create(&p).await.unwrap();
assert!(matches!(r.create(&p).await.unwrap_err(), DbError::Conflict(_)));
}
}
@@ -0,0 +1,64 @@
use sqlx::SqlitePool;
use crate::error::DbError;
use crate::models::AttachmentRow;
use crate::repository::attachment::IAttachmentRepository;
#[derive(Clone, Debug)]
pub struct SqliteAttachmentRepository {
pool: SqlitePool,
}
impl SqliteAttachmentRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait::async_trait]
impl IAttachmentRepository for SqliteAttachmentRepository {
async fn insert(&self, row: &AttachmentRow) -> Result<(), DbError> {
sqlx::query(
"INSERT INTO attachments (\
id, requirement_id, file_name, rel_path, mime, size_bytes, created_by, created_at\
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&row.id)
.bind(row.requirement_id)
.bind(&row.file_name)
.bind(&row.rel_path)
.bind(&row.mime)
.bind(row.size_bytes)
.bind(&row.created_by)
.bind(row.created_at)
.execute(&self.pool)
.await?;
Ok(())
}
async fn get_by_id(&self, id: &str) -> Result<Option<AttachmentRow>, DbError> {
let row = sqlx::query_as::<_, AttachmentRow>("SELECT * FROM attachments WHERE id = ?")
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
async fn list_for_requirement(&self, requirement_id: i64) -> Result<Vec<AttachmentRow>, DbError> {
let rows = sqlx::query_as::<_, AttachmentRow>(
"SELECT * FROM attachments WHERE requirement_id = ? ORDER BY created_at ASC, id ASC",
)
.bind(requirement_id)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
async fn delete(&self, id: &str) -> Result<bool, DbError> {
let result = sqlx::query("DELETE FROM attachments WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
}
}
@@ -0,0 +1,383 @@
use sqlx::SqlitePool;
use crate::error::DbError;
use crate::models::{AuditLogRow, CreateAuditLogParams, PaginatedAuditLogs, QueryAuditLogParams};
use crate::repository::IAuditLogRepository;
/// SQLite-backed implementation of [`IAuditLogRepository`].
#[derive(Clone, Debug)]
pub struct SqliteAuditLogRepository {
pool: SqlitePool,
}
impl SqliteAuditLogRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait::async_trait]
impl IAuditLogRepository for SqliteAuditLogRepository {
async fn create(&self, params: CreateAuditLogParams) -> Result<AuditLogRow, DbError> {
let now = nomifun_common::now_ms();
let details_json = serde_json::to_string(&params.details)
.map_err(|e| DbError::Init(format!("serialize details: {}", e)))?;
let result = sqlx::query(
"INSERT INTO audit_log \
(action, category, user_id, username, ip_address, user_agent, \
resource_type, resource_id, details, status, created_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&params.action)
.bind(params.category.as_str())
.bind(&params.user_id)
.bind(&params.username)
.bind(&params.ip_address)
.bind(&params.user_agent)
.bind(&params.resource_type)
.bind(&params.resource_id)
.bind(&details_json)
.bind(params.status.as_str())
.bind(now)
.execute(&self.pool)
.await
.map_err(DbError::Query)?;
let id = result.last_insert_rowid();
self.get_by_id(id)
.await?
.ok_or_else(|| DbError::Init("failed to re-fetch inserted audit log".into()))
}
async fn query(&self, params: QueryAuditLogParams) -> Result<PaginatedAuditLogs, DbError> {
let page = params.page();
let page_size = params.page_size();
let offset = params.offset();
// Build WHERE clause dynamically
let mut conditions = Vec::new();
let mut bindings: Vec<Option<String>> = Vec::new();
if let Some(start) = params.start_date {
conditions.push("created_at >= ?");
bindings.push(Some(start.to_string()));
}
if let Some(end) = params.end_date {
conditions.push("created_at <= ?");
bindings.push(Some(end.to_string()));
}
if let Some(ref action) = params.action {
conditions.push("action = ?");
bindings.push(Some(action.clone()));
}
if let Some(ref user_id) = params.user_id {
conditions.push("user_id = ?");
bindings.push(Some(user_id.clone()));
}
if let Some(ref cat) = params.category {
conditions.push("category = ?");
bindings.push(Some(cat.clone()));
}
if let Some(ref status) = params.status {
conditions.push("status = ?");
bindings.push(Some(status.clone()));
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
// Count total
let count_query = format!("SELECT COUNT(*) as count FROM audit_log {}", where_clause);
let mut count_q = sqlx::query_scalar::<_, i64>(&count_query);
for b in &bindings {
if let Some(v) = b {
count_q = count_q.bind(v);
}
}
let total: i64 = count_q.fetch_one(&self.pool).await.map_err(DbError::Query)?;
let total_pages = ((total as f64) / (page_size as f64)).ceil() as u32;
// Fetch page
let select_query = format!(
"SELECT id, action, category, user_id, username, ip_address, user_agent, \
resource_type, resource_id, details, status, created_at \
FROM audit_log {} \
ORDER BY created_at DESC \
LIMIT ? OFFSET ?",
where_clause
);
let mut select_q = sqlx::query_as::<_, AuditLogRow>(&select_query);
for b in &bindings {
if let Some(v) = b {
select_q = select_q.bind(v);
}
}
select_q = select_q.bind(page_size as i64);
select_q = select_q.bind(offset as i64);
let items: Vec<AuditLogRow> = select_q.fetch_all(&self.pool).await.map_err(DbError::Query)?;
Ok(PaginatedAuditLogs {
items,
total,
page,
page_size,
total_pages,
})
}
async fn get_by_id(&self, id: i64) -> Result<Option<AuditLogRow>, DbError> {
sqlx::query_as::<_, AuditLogRow>(
"SELECT id, action, category, user_id, username, ip_address, user_agent, \
resource_type, resource_id, details, status, created_at \
FROM audit_log WHERE id = ?",
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(DbError::Query)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{AuditCategory, AuditStatus};
async fn setup() -> (SqliteAuditLogRepository, crate::Database) {
let db = crate::init_database_memory().await.unwrap();
let repo = SqliteAuditLogRepository::new(db.pool().clone());
(repo, db)
}
#[tokio::test]
async fn create_inserts_row() {
let (repo, _db) = setup().await;
let params = CreateAuditLogParams {
action: "login".to_string(),
category: AuditCategory::Auth,
user_id: Some("user-1".to_string()),
username: Some("alice".to_string()),
ip_address: Some("192.168.1.1".to_string()),
user_agent: Some("Mozilla/5.0".to_string()),
resource_type: None,
resource_id: None,
details: serde_json::json!({"method": "password"}),
status: AuditStatus::Success,
};
let row = repo.create(params).await.unwrap();
assert_eq!(row.action, "login");
assert_eq!(row.category, "auth");
assert_eq!(row.user_id.as_deref(), Some("user-1"));
assert_eq!(row.username.as_deref(), Some("alice"));
assert_eq!(row.status, "success");
assert!(row.id > 0);
}
#[tokio::test]
async fn query_returns_paginated_results() {
let (repo, _db) = setup().await;
// Insert 5 entries
for i in 0..5 {
repo.create(CreateAuditLogParams {
action: format!("action_{}", i),
category: AuditCategory::Other,
user_id: None,
username: None,
ip_address: None,
user_agent: None,
resource_type: None,
resource_id: None,
details: serde_json::json!({}),
status: AuditStatus::Success,
})
.await
.unwrap();
}
let result = repo.query(QueryAuditLogParams::default()).await.unwrap();
assert_eq!(result.items.len(), 5);
assert_eq!(result.total, 5);
assert_eq!(result.page, 1);
assert_eq!(result.page_size, 50);
}
#[tokio::test]
async fn query_filters_by_category() {
let (repo, _db) = setup().await;
repo.create(CreateAuditLogParams {
action: "login".to_string(),
category: AuditCategory::Auth,
user_id: None,
username: None,
ip_address: None,
user_agent: None,
resource_type: None,
resource_id: None,
details: serde_json::json!({}),
status: AuditStatus::Success,
})
.await
.unwrap();
repo.create(CreateAuditLogParams {
action: "branding_update".to_string(),
category: AuditCategory::Branding,
user_id: None,
username: None,
ip_address: None,
user_agent: None,
resource_type: None,
resource_id: None,
details: serde_json::json!({}),
status: AuditStatus::Success,
})
.await
.unwrap();
let result = repo
.query(QueryAuditLogParams {
category: Some("auth".to_string()),
..Default::default()
})
.await
.unwrap();
assert_eq!(result.items.len(), 1);
assert_eq!(result.items[0].action, "login");
}
#[tokio::test]
async fn query_filters_by_user_id() {
let (repo, _db) = setup().await;
repo.create(CreateAuditLogParams {
action: "login".to_string(),
category: AuditCategory::Auth,
user_id: Some("alice".to_string()),
username: Some("alice".to_string()),
ip_address: None,
user_agent: None,
resource_type: None,
resource_id: None,
details: serde_json::json!({}),
status: AuditStatus::Success,
})
.await
.unwrap();
repo.create(CreateAuditLogParams {
action: "login".to_string(),
category: AuditCategory::Auth,
user_id: Some("bob".to_string()),
username: Some("bob".to_string()),
ip_address: None,
user_agent: None,
resource_type: None,
resource_id: None,
details: serde_json::json!({}),
status: AuditStatus::Success,
})
.await
.unwrap();
let result = repo
.query(QueryAuditLogParams {
user_id: Some("alice".to_string()),
..Default::default()
})
.await
.unwrap();
assert_eq!(result.items.len(), 1);
assert_eq!(result.items[0].user_id.as_deref(), Some("alice"));
}
#[tokio::test]
async fn query_pagination() {
let (repo, _db) = setup().await;
for i in 0..10 {
repo.create(CreateAuditLogParams {
action: format!("action_{}", i),
category: AuditCategory::Other,
user_id: None,
username: None,
ip_address: None,
user_agent: None,
resource_type: None,
resource_id: None,
details: serde_json::json!({}),
status: AuditStatus::Success,
})
.await
.unwrap();
}
let page1 = repo
.query(QueryAuditLogParams {
page: Some(1),
page_size: Some(3),
..Default::default()
})
.await
.unwrap();
assert_eq!(page1.items.len(), 3);
assert_eq!(page1.total, 10);
assert_eq!(page1.total_pages, 4);
let page2 = repo
.query(QueryAuditLogParams {
page: Some(2),
page_size: Some(3),
..Default::default()
})
.await
.unwrap();
assert_eq!(page2.items.len(), 3);
assert_eq!(page2.page, 2);
}
#[tokio::test]
async fn get_by_id_returns_row() {
let (repo, _db) = setup().await;
let created = repo
.create(CreateAuditLogParams {
action: "logout".to_string(),
category: AuditCategory::Auth,
user_id: None,
username: None,
ip_address: None,
user_agent: None,
resource_type: None,
resource_id: None,
details: serde_json::json!({}),
status: AuditStatus::Success,
})
.await
.unwrap();
let fetched = repo.get_by_id(created.id).await.unwrap().unwrap();
assert_eq!(fetched.id, created.id);
assert_eq!(fetched.action, "logout");
}
#[tokio::test]
async fn get_by_id_returns_none_for_missing() {
let (repo, _db) = setup().await;
let result = repo.get_by_id(9999).await.unwrap();
assert!(result.is_none());
}
}
@@ -0,0 +1,278 @@
use sqlx::SqlitePool;
use crate::error::DbError;
use crate::models::{BrandingConfigRow, ThemePreset, UpdateBrandingParams};
use crate::repository::IBrandingConfigRepository;
/// SQLite-backed implementation of [`IBrandingConfigRepository`].
#[derive(Clone, Debug)]
pub struct SqliteBrandingConfigRepository {
pool: SqlitePool,
}
impl SqliteBrandingConfigRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
/// Helper: build dynamic UPDATE SET clause from non-None fields.
fn build_update_query(
params: &UpdateBrandingParams,
) -> (String, Vec<Option<String>>) {
let mut set_parts = Vec::new();
let mut values: Vec<Option<String>> = Vec::new();
macro_rules! push_opt {
($field:ident, $col:literal) => {
if params.$field.is_some() {
set_parts.push(concat!($col, " = ?"));
values.push(params.$field.clone());
}
};
}
push_opt!(logo_light, "logo_light");
push_opt!(logo_dark, "logo_dark");
push_opt!(logo_favicon, "logo_favicon");
push_opt!(primary_color, "primary_color");
push_opt!(secondary_color, "secondary_color");
push_opt!(accent_color, "accent_color");
push_opt!(background_light, "background_light");
push_opt!(background_dark, "background_dark");
push_opt!(surface_light, "surface_light");
push_opt!(surface_dark, "surface_dark");
push_opt!(text_primary_light, "text_primary_light");
push_opt!(text_primary_dark, "text_primary_dark");
push_opt!(text_secondary_light, "text_secondary_light");
push_opt!(text_secondary_dark, "text_secondary_dark");
push_opt!(border_light, "border_light");
push_opt!(border_dark, "border_dark");
push_opt!(custom_css, "custom_css");
(set_parts.join(", "), values)
}
/// Fetch the single row from the DB.
async fn fetch_row(&self) -> Result<Option<BrandingConfigRow>, DbError> {
sqlx::query_as::<_, BrandingConfigRow>(
"SELECT id, logo_light, logo_dark, logo_favicon, \
primary_color, secondary_color, accent_color, \
background_light, background_dark, surface_light, surface_dark, \
text_primary_light, text_primary_dark, \
text_secondary_light, text_secondary_dark, \
border_light, border_dark, \
active_preset, custom_css, created_at, updated_at \
FROM branding_config WHERE id = 1",
)
.fetch_optional(&self.pool)
.await
.map_err(DbError::Query)
}
}
#[async_trait::async_trait]
impl IBrandingConfigRepository for SqliteBrandingConfigRepository {
async fn get_config(&self) -> Result<Option<BrandingConfigRow>, DbError> {
self.fetch_row().await
}
async fn update_config(
&self,
params: UpdateBrandingParams,
) -> Result<BrandingConfigRow, DbError> {
let (set_clause, values) = Self::build_update_query(&params);
if set_clause.is_empty() {
// No fields to update — just return current config
return self
.fetch_row()
.await?
.ok_or_else(|| DbError::NotFound("branding_config row not found".into()));
}
let now = nomifun_common::now_ms();
let query = format!(
"UPDATE branding_config SET {}, updated_at = ? WHERE id = 1",
set_clause
);
let mut q = sqlx::query(&query);
for v in &values {
q = q.bind(v);
}
q = q.bind(now);
q.execute(&self.pool).await.map_err(DbError::Query)?;
self.fetch_row()
.await?
.ok_or_else(|| DbError::NotFound("branding_config row not found".into()))
}
async fn apply_preset(&self, preset_id: &str) -> Result<BrandingConfigRow, DbError> {
let preset = ThemePreset::all_presets()
.into_iter()
.find(|p| p.id == preset_id)
.ok_or_else(|| DbError::NotFound(format!("Unknown preset: {}", preset_id)))?;
let params = UpdateBrandingParams {
primary_color: Some(preset.colors.primary_color),
secondary_color: Some(preset.colors.secondary_color),
accent_color: Some(preset.colors.accent_color),
background_light: Some(preset.colors.background_light),
background_dark: Some(preset.colors.background_dark),
surface_light: Some(preset.colors.surface_light),
surface_dark: Some(preset.colors.surface_dark),
text_primary_light: Some(preset.colors.text_primary_light),
text_primary_dark: Some(preset.colors.text_primary_dark),
text_secondary_light: Some(preset.colors.text_secondary_light),
text_secondary_dark: Some(preset.colors.text_secondary_dark),
border_light: Some(preset.colors.border_light),
border_dark: Some(preset.colors.border_dark),
..Default::default()
};
// Update colors + active_preset in one shot
let now = nomifun_common::now_ms();
sqlx::query(
"UPDATE branding_config SET \
primary_color = ?, secondary_color = ?, accent_color = ?, \
background_light = ?, background_dark = ?, \
surface_light = ?, surface_dark = ?, \
text_primary_light = ?, text_primary_dark = ?, \
text_secondary_light = ?, text_secondary_dark = ?, \
border_light = ?, border_dark = ?, \
active_preset = ?, updated_at = ? \
WHERE id = 1",
)
.bind(&params.primary_color.as_ref().unwrap())
.bind(&params.secondary_color.as_ref().unwrap())
.bind(&params.accent_color.as_ref().unwrap())
.bind(&params.background_light.as_ref().unwrap())
.bind(&params.background_dark.as_ref().unwrap())
.bind(&params.surface_light.as_ref().unwrap())
.bind(&params.surface_dark.as_ref().unwrap())
.bind(&params.text_primary_light.as_ref().unwrap())
.bind(&params.text_primary_dark.as_ref().unwrap())
.bind(&params.text_secondary_light.as_ref().unwrap())
.bind(&params.text_secondary_dark.as_ref().unwrap())
.bind(&params.border_light.as_ref().unwrap())
.bind(&params.border_dark.as_ref().unwrap())
.bind(preset_id)
.bind(now)
.execute(&self.pool)
.await
.map_err(DbError::Query)?;
self.fetch_row()
.await?
.ok_or_else(|| DbError::NotFound("branding_config row not found".into()))
}
async fn reset_to_default(&self) -> Result<BrandingConfigRow, DbError> {
self.apply_preset("enterprise_blue").await
}
}
impl Default for UpdateBrandingParams {
fn default() -> Self {
Self {
logo_light: None,
logo_dark: None,
logo_favicon: None,
primary_color: None,
secondary_color: None,
accent_color: None,
background_light: None,
background_dark: None,
surface_light: None,
surface_dark: None,
text_primary_light: None,
text_primary_dark: None,
text_secondary_light: None,
text_secondary_dark: None,
border_light: None,
border_dark: None,
custom_css: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
async fn setup() -> (SqliteBrandingConfigRepository, crate::Database) {
let db = crate::init_database_memory().await.unwrap();
let repo = SqliteBrandingConfigRepository::new(db.pool().clone());
(repo, db)
}
#[tokio::test]
async fn get_config_returns_default_from_migration() {
let (repo, _db) = setup().await;
let cfg = repo.get_config().await.unwrap().unwrap();
assert_eq!(cfg.id, 1);
assert_eq!(cfg.primary_color, "#3B82F6");
assert_eq!(cfg.active_preset, "default");
assert!(cfg.custom_css.is_empty());
}
#[tokio::test]
async fn update_config_partial_fields() {
let (repo, _db) = setup().await;
let params = UpdateBrandingParams {
primary_color: Some("#FF0000".to_string()),
custom_css: Some("body { color: red; }".to_string()),
..Default::default()
};
let cfg = repo.update_config(params).await.unwrap();
assert_eq!(cfg.primary_color, "#FF0000");
assert_eq!(cfg.custom_css, "body { color: red; }");
assert_eq!(cfg.secondary_color, "#64748B"); // unchanged
}
#[tokio::test]
async fn update_config_noop_when_empty() {
let (repo, _db) = setup().await;
let cfg = repo.update_config(Default::default()).await.unwrap();
assert_eq!(cfg.primary_color, "#3B82F6");
}
#[tokio::test]
async fn apply_preset_changes_colors() {
let (repo, _db) = setup().await;
let cfg = repo.apply_preset("government_blue").await.unwrap();
assert_eq!(cfg.active_preset, "government_blue");
assert_eq!(cfg.primary_color, "#1E40AF");
assert_eq!(cfg.background_light, "#F0F4F8");
}
#[tokio::test]
async fn apply_preset_unknown_returns_error() {
let (repo, _db) = setup().await;
let result = repo.apply_preset("nonexistent").await;
assert!(result.is_err());
}
#[tokio::test]
async fn reset_to_default_restores_enterprise_blue() {
let (repo, _db) = setup().await;
// Change first
repo.update_config(UpdateBrandingParams {
primary_color: Some("#DEADBEEF".to_string()),
..Default::default()
})
.await
.unwrap();
// Reset
let cfg = repo.reset_to_default().await.unwrap();
assert_eq!(cfg.primary_color, "#3B82F6");
assert_eq!(cfg.active_preset, "enterprise_blue");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,197 @@
use sqlx::SqlitePool;
use crate::error::DbError;
use crate::models::ClientPreference;
use crate::repository::IClientPreferenceRepository;
/// SQLite-backed implementation of [`IClientPreferenceRepository`].
#[derive(Clone, Debug)]
pub struct SqliteClientPreferenceRepository {
pool: SqlitePool,
}
impl SqliteClientPreferenceRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait::async_trait]
impl IClientPreferenceRepository for SqliteClientPreferenceRepository {
async fn get_all(&self) -> Result<Vec<ClientPreference>, DbError> {
let rows = sqlx::query_as::<_, ClientPreference>("SELECT * FROM client_preferences ORDER BY key")
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
async fn get_by_keys(&self, keys: &[&str]) -> Result<Vec<ClientPreference>, DbError> {
if keys.is_empty() {
return Ok(vec![]);
}
// Build dynamic IN clause with positional placeholders
let placeholders: Vec<&str> = keys.iter().map(|_| "?").collect();
let sql = format!(
"SELECT * FROM client_preferences WHERE key IN ({}) ORDER BY key",
placeholders.join(", ")
);
let mut query = sqlx::query_as::<_, ClientPreference>(&sql);
for key in keys {
query = query.bind(*key);
}
let rows = query.fetch_all(&self.pool).await?;
Ok(rows)
}
async fn upsert_batch(&self, entries: &[(&str, &str)]) -> Result<(), DbError> {
if entries.is_empty() {
return Ok(());
}
let now = nomifun_common::now_ms();
// Use a transaction for atomicity
let mut tx = self.pool.begin().await?;
for (key, value) in entries {
sqlx::query(
"INSERT INTO client_preferences (key, value, updated_at) \
VALUES (?, ?, ?) \
ON CONFLICT(key) DO UPDATE SET \
value = excluded.value, \
updated_at = excluded.updated_at",
)
.bind(*key)
.bind(*value)
.bind(now)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
async fn delete_keys(&self, keys: &[&str]) -> Result<(), DbError> {
if keys.is_empty() {
return Ok(());
}
let placeholders: Vec<&str> = keys.iter().map(|_| "?").collect();
let sql = format!(
"DELETE FROM client_preferences WHERE key IN ({})",
placeholders.join(", ")
);
let mut query = sqlx::query(&sql);
for key in keys {
query = query.bind(*key);
}
query.execute(&self.pool).await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::init_database_memory;
async fn setup() -> (SqliteClientPreferenceRepository, crate::Database) {
let db = init_database_memory().await.unwrap();
let repo = SqliteClientPreferenceRepository::new(db.pool().clone());
(repo, db)
}
#[tokio::test]
async fn get_all_empty() {
let (repo, _db) = setup().await;
let prefs = repo.get_all().await.unwrap();
assert!(prefs.is_empty());
}
#[tokio::test]
async fn upsert_and_get_all() {
let (repo, _db) = setup().await;
repo.upsert_batch(&[("theme", "\"dark\""), ("companion.size", "360")])
.await
.unwrap();
let prefs = repo.get_all().await.unwrap();
assert_eq!(prefs.len(), 2);
assert_eq!(prefs[0].key, "companion.size");
assert_eq!(prefs[0].value, "360");
assert_eq!(prefs[1].key, "theme");
assert_eq!(prefs[1].value, "\"dark\"");
}
#[tokio::test]
async fn get_by_keys_filters_correctly() {
let (repo, _db) = setup().await;
repo.upsert_batch(&[("a", "1"), ("b", "2"), ("c", "3")]).await.unwrap();
let prefs = repo.get_by_keys(&["a", "c", "nonexistent"]).await.unwrap();
assert_eq!(prefs.len(), 2);
let keys: Vec<&str> = prefs.iter().map(|p| p.key.as_str()).collect();
assert!(keys.contains(&"a"));
assert!(keys.contains(&"c"));
}
#[tokio::test]
async fn get_by_keys_empty_input() {
let (repo, _db) = setup().await;
let prefs = repo.get_by_keys(&[]).await.unwrap();
assert!(prefs.is_empty());
}
#[tokio::test]
async fn upsert_overwrites_existing_key() {
let (repo, _db) = setup().await;
repo.upsert_batch(&[("k", "v1")]).await.unwrap();
repo.upsert_batch(&[("k", "v2")]).await.unwrap();
let prefs = repo.get_all().await.unwrap();
assert_eq!(prefs.len(), 1);
assert_eq!(prefs[0].value, "v2");
}
#[tokio::test]
async fn delete_keys_removes_entries() {
let (repo, _db) = setup().await;
repo.upsert_batch(&[("a", "1"), ("b", "2"), ("c", "3")]).await.unwrap();
repo.delete_keys(&["a", "c"]).await.unwrap();
let prefs = repo.get_all().await.unwrap();
assert_eq!(prefs.len(), 1);
assert_eq!(prefs[0].key, "b");
}
#[tokio::test]
async fn delete_keys_nonexistent_is_noop() {
let (repo, _db) = setup().await;
repo.delete_keys(&["ghost"]).await.unwrap();
assert!(repo.get_all().await.unwrap().is_empty());
}
#[tokio::test]
async fn delete_keys_empty_input() {
let (repo, _db) = setup().await;
repo.upsert_batch(&[("x", "1")]).await.unwrap();
repo.delete_keys(&[]).await.unwrap();
assert_eq!(repo.get_all().await.unwrap().len(), 1);
}
#[tokio::test]
async fn upsert_empty_batch_is_noop() {
let (repo, _db) = setup().await;
repo.upsert_batch(&[]).await.unwrap();
assert!(repo.get_all().await.unwrap().is_empty());
}
}
@@ -0,0 +1,90 @@
use sqlx::SqlitePool;
use crate::error::DbError;
use crate::repository::ICompanionTokenRepository;
/// SQLite-backed [`ICompanionTokenRepository`]. Keyed on `companion_id`;
/// `upsert_for_companion` rotates a companion's single token row.
#[derive(Clone, Debug)]
pub struct SqliteCompanionTokenRepository {
pool: SqlitePool,
}
impl SqliteCompanionTokenRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait::async_trait]
impl ICompanionTokenRepository for SqliteCompanionTokenRepository {
async fn list_all(&self) -> Result<Vec<(String, String)>, DbError> {
let rows = sqlx::query_as::<_, (String, String)>(
"SELECT companion_id, token_hash FROM companion_access_token",
)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
async fn upsert_for_companion(&self, companion_id: &str, token_hash: &str) -> Result<(), DbError> {
let now = nomifun_common::now_ms();
sqlx::query(
"INSERT INTO companion_access_token (companion_id, token_hash, created_at) VALUES (?1, ?2, ?3) \
ON CONFLICT(companion_id) DO UPDATE SET token_hash = ?2, created_at = ?3",
)
.bind(companion_id)
.bind(token_hash)
.bind(now)
.execute(&self.pool)
.await?;
Ok(())
}
async fn delete_for_companion(&self, companion_id: &str) -> Result<(), DbError> {
sqlx::query("DELETE FROM companion_access_token WHERE companion_id = ?1")
.bind(companion_id)
.execute(&self.pool)
.await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::init_database_memory;
#[tokio::test]
async fn companion_token_roundtrip() {
let db = init_database_memory().await.unwrap();
let repo = SqliteCompanionTokenRepository::new(db.pool().clone());
// Empty until minted.
assert!(repo.list_all().await.unwrap().is_empty());
repo.upsert_for_companion("comp-a", "hash-a").await.unwrap();
repo.upsert_for_companion("comp-b", "hash-b").await.unwrap();
let mut all = repo.list_all().await.unwrap();
all.sort();
assert_eq!(
all,
vec![
("comp-a".to_string(), "hash-a".to_string()),
("comp-b".to_string(), "hash-b".to_string()),
]
);
// Re-mint for the same companion rotates its hash (keyed on companion_id).
repo.upsert_for_companion("comp-a", "hash-a2").await.unwrap();
let all = repo.list_all().await.unwrap();
assert!(all.contains(&("comp-a".to_string(), "hash-a2".to_string())));
assert_eq!(all.len(), 2);
// Revocation is idempotent.
repo.delete_for_companion("comp-a").await.unwrap();
repo.delete_for_companion("comp-a").await.unwrap();
let all = repo.list_all().await.unwrap();
assert_eq!(all, vec![("comp-b".to_string(), "hash-b".to_string())]);
}
}
@@ -0,0 +1,101 @@
use sqlx::SqlitePool;
use crate::error::DbError;
use crate::models::ConnectorCredentialRow;
use crate::repository::IConnectorCredentialRepository;
/// SQLite-backed [`IConnectorCredentialRepository`].
#[derive(Clone, Debug)]
pub struct SqliteConnectorCredentialRepository {
pool: SqlitePool,
}
impl SqliteConnectorCredentialRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait::async_trait]
impl IConnectorCredentialRepository for SqliteConnectorCredentialRepository {
async fn list(&self) -> Result<Vec<ConnectorCredentialRow>, DbError> {
let rows = sqlx::query_as::<_, ConnectorCredentialRow>(
"SELECT * FROM connector_credentials ORDER BY created_at ASC",
)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
async fn get(&self, id: &str) -> Result<Option<ConnectorCredentialRow>, DbError> {
let row = sqlx::query_as::<_, ConnectorCredentialRow>("SELECT * FROM connector_credentials WHERE id = ?")
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
async fn create(&self, kind: &str, name: &str, payload_encrypted: &str) -> Result<ConnectorCredentialRow, DbError> {
let id = nomifun_common::generate_prefixed_id("conn");
let now = nomifun_common::now_ms();
sqlx::query(
"INSERT INTO connector_credentials (id, kind, name, payload_encrypted, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(kind)
.bind(name)
.bind(payload_encrypted)
.bind(now)
.bind(now)
.execute(&self.pool)
.await?;
Ok(ConnectorCredentialRow {
id,
kind: kind.to_owned(),
name: name.to_owned(),
payload_encrypted: payload_encrypted.to_owned(),
created_at: now,
updated_at: now,
})
}
async fn delete(&self, id: &str) -> Result<(), DbError> {
let res = sqlx::query("DELETE FROM connector_credentials WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
if res.rows_affected() == 0 {
return Err(DbError::NotFound(id.to_owned()));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::init_database_memory;
#[tokio::test]
async fn connector_credential_crud_roundtrip() {
let db = init_database_memory().await.unwrap();
let repo = SqliteConnectorCredentialRepository::new(db.pool().clone());
let row = repo.create("feishu", "我的飞书", "ENC(payload)").await.unwrap();
assert!(row.id.starts_with("conn"), "id prefixed: {}", row.id);
let got = repo.get(&row.id).await.unwrap().unwrap();
assert_eq!(got.kind, "feishu");
assert_eq!(got.name, "我的飞书");
assert_eq!(got.payload_encrypted, "ENC(payload)");
// A second credential of the same kind is allowed (different tenant).
repo.create("feishu", "另一个飞书", "ENC(other)").await.unwrap();
assert_eq!(repo.list().await.unwrap().len(), 2);
repo.delete(&row.id).await.unwrap();
assert!(repo.get(&row.id).await.unwrap().is_none());
assert!(matches!(repo.delete(&row.id).await, Err(DbError::NotFound(_))), "second delete errors");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,627 @@
use nomifun_common::now_ms;
use sqlx::SqlitePool;
use crate::error::DbError;
use crate::models::{CronJobRow, CronJobRunRow};
use crate::repository::bind::{BindValue, bind_value};
use crate::repository::cron::{CRON_RUN_HISTORY_LIMIT, ICronRepository, UpdateCronJobParams};
#[derive(Clone, Debug)]
pub struct SqliteCronRepository {
pool: SqlitePool,
}
impl SqliteCronRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait::async_trait]
impl ICronRepository for SqliteCronRepository {
async fn insert(&self, row: &CronJobRow) -> Result<(), DbError> {
sqlx::query(
"INSERT INTO cron_jobs (\
id, name, enabled, schedule_kind, schedule_value, schedule_tz, \
schedule_description, payload_message, execution_mode, agent_config, \
conversation_id, conversation_title, agent_type, created_by, \
skill_content, description, created_at, updated_at, next_run_at, last_run_at, \
last_status, last_error, run_count, retry_count, max_retries, \
target_kind, terminal_mode, terminal_session_id, terminal_command, terminal_args, terminal_script\
) VALUES (\
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?\
)",
)
.bind(&row.id)
.bind(&row.name)
.bind(row.enabled)
.bind(&row.schedule_kind)
.bind(&row.schedule_value)
.bind(&row.schedule_tz)
.bind(&row.schedule_description)
.bind(&row.payload_message)
.bind(&row.execution_mode)
.bind(&row.agent_config)
.bind(&row.conversation_id)
.bind(&row.conversation_title)
.bind(&row.agent_type)
.bind(&row.created_by)
.bind(&row.skill_content)
.bind(&row.description)
.bind(row.created_at)
.bind(row.updated_at)
.bind(row.next_run_at)
.bind(row.last_run_at)
.bind(&row.last_status)
.bind(&row.last_error)
.bind(row.run_count)
.bind(row.retry_count)
.bind(row.max_retries)
.bind(&row.target_kind)
.bind(&row.terminal_mode)
.bind(&row.terminal_session_id)
.bind(&row.terminal_command)
.bind(&row.terminal_args)
.bind(&row.terminal_script)
.execute(&self.pool)
.await?;
Ok(())
}
async fn update(&self, id: &str, params: &UpdateCronJobParams) -> Result<(), DbError> {
let mut set_parts: Vec<String> = Vec::new();
let mut binds: Vec<BindValue> = Vec::new();
macro_rules! push_str {
($field:ident) => {
if let Some(ref v) = params.$field {
set_parts.push(concat!(stringify!($field), " = ?").to_string());
binds.push(BindValue::Str(v.clone()));
}
};
}
macro_rules! push_opt_str {
($field:ident) => {
if let Some(ref v) = params.$field {
set_parts.push(concat!(stringify!($field), " = ?").to_string());
binds.push(BindValue::OptStr(v.clone()));
}
};
}
macro_rules! push_opt_i64 {
($field:ident) => {
if let Some(ref v) = params.$field {
set_parts.push(concat!(stringify!($field), " = ?").to_string());
binds.push(BindValue::OptI64(*v));
}
};
}
macro_rules! push_i64 {
($field:ident) => {
if let Some(v) = params.$field {
set_parts.push(concat!(stringify!($field), " = ?").to_string());
binds.push(BindValue::I64(v));
}
};
}
if let Some(v) = params.enabled {
set_parts.push("enabled = ?".to_string());
binds.push(BindValue::Bool(v));
}
push_str!(name);
push_str!(schedule_kind);
push_str!(schedule_value);
push_opt_str!(schedule_tz);
push_opt_str!(schedule_description);
push_str!(payload_message);
push_str!(execution_mode);
push_opt_str!(agent_config);
push_opt_i64!(conversation_id);
push_opt_str!(conversation_title);
push_str!(agent_type);
push_opt_str!(skill_content);
push_opt_str!(description);
push_opt_i64!(next_run_at);
push_opt_i64!(last_run_at);
push_opt_str!(last_status);
push_opt_str!(last_error);
push_i64!(run_count);
push_i64!(retry_count);
push_str!(target_kind);
push_opt_str!(terminal_mode);
push_opt_i64!(terminal_session_id);
push_opt_str!(terminal_command);
push_opt_str!(terminal_args);
push_opt_str!(terminal_script);
if set_parts.is_empty() {
return Ok(());
}
set_parts.push("updated_at = ?".to_string());
binds.push(BindValue::I64(now_ms()));
let sql = format!("UPDATE cron_jobs SET {} WHERE id = ?", set_parts.join(", "));
let mut query = sqlx::query(&sql);
for bind in &binds {
query = bind_value(query, bind);
}
query = query.bind(id);
let result = query.execute(&self.pool).await?;
if result.rows_affected() == 0 {
return Err(DbError::NotFound(format!("cron job '{id}'")));
}
Ok(())
}
async fn delete(&self, id: &str) -> Result<(), DbError> {
let result = sqlx::query("DELETE FROM cron_jobs WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(DbError::NotFound(format!("cron job '{id}'")));
}
Ok(())
}
async fn get_by_id(&self, id: &str) -> Result<Option<CronJobRow>, DbError> {
let row = sqlx::query_as::<_, CronJobRow>("SELECT * FROM cron_jobs WHERE id = ?")
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
async fn list_all(&self) -> Result<Vec<CronJobRow>, DbError> {
let rows =
sqlx::query_as::<_, CronJobRow>("SELECT * FROM cron_jobs ORDER BY created_at ASC")
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
async fn list_enabled(&self) -> Result<Vec<CronJobRow>, DbError> {
let rows = sqlx::query_as::<_, CronJobRow>(
"SELECT * FROM cron_jobs WHERE enabled = 1 ORDER BY created_at ASC",
)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
async fn list_by_conversation(&self, conversation_id: i64) -> Result<Vec<CronJobRow>, DbError> {
let rows = sqlx::query_as::<_, CronJobRow>(
"SELECT * FROM cron_jobs WHERE conversation_id = ? ORDER BY created_at ASC",
)
.bind(conversation_id)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
async fn delete_by_conversation(&self, conversation_id: i64) -> Result<u64, DbError> {
let result = sqlx::query("DELETE FROM cron_jobs WHERE conversation_id = ?")
.bind(conversation_id)
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
async fn insert_run_pruned(&self, row: &CronJobRunRow) -> Result<(), DbError> {
let mut tx = self.pool.begin().await?;
sqlx::query(
"INSERT INTO cron_job_runs (id, job_id, executed_at_ms, status, created_at_ms) \
VALUES (?, ?, ?, ?, ?)",
)
.bind(&row.id)
.bind(&row.job_id)
.bind(row.executed_at_ms)
.bind(&row.status)
.bind(row.created_at_ms)
.execute(&mut *tx)
.await?;
sqlx::query(
"DELETE FROM cron_job_runs \
WHERE job_id = ? \
AND id NOT IN (\
SELECT id FROM cron_job_runs \
WHERE job_id = ? \
ORDER BY executed_at_ms DESC, created_at_ms DESC, id DESC \
LIMIT ?\
)",
)
.bind(&row.job_id)
.bind(&row.job_id)
.bind(CRON_RUN_HISTORY_LIMIT)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
async fn list_runs_by_job(
&self,
job_id: &str,
limit: i64,
) -> Result<Vec<CronJobRunRow>, DbError> {
let limit = limit.clamp(0, CRON_RUN_HISTORY_LIMIT);
let rows = sqlx::query_as::<_, CronJobRunRow>(
"SELECT * FROM cron_job_runs \
WHERE job_id = ? \
ORDER BY executed_at_ms DESC, created_at_ms DESC, id DESC \
LIMIT ?",
)
.bind(job_id)
.bind(limit)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::init_database_memory;
use crate::models::CronJobRunRow;
async fn setup() -> (SqliteCronRepository, crate::Database) {
let db = init_database_memory().await.expect("init db");
let repo = SqliteCronRepository::new(db.pool().clone());
// Insert a user + conversation so FK-like constraints hold logically
sqlx::query(
"INSERT INTO users (id, username, password_hash, created_at, updated_at) \
VALUES ('user_1', 'tester', 'hash', 0, 0)",
)
.execute(db.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO conversations (id, user_id, name, type, created_at, updated_at) \
VALUES (1, 'user_1', 'Test Conv', 'normal', 0, 0)",
)
.execute(db.pool())
.await
.unwrap();
(repo, db)
}
fn make_row(id: &str) -> CronJobRow {
let now = now_ms();
CronJobRow {
id: id.into(),
name: "Test Job".into(),
enabled: true,
schedule_kind: "every".into(),
schedule_value: "60000".into(),
schedule_tz: None,
schedule_description: Some("Every minute".into()),
payload_message: "ping".into(),
execution_mode: "existing".into(),
agent_config: None,
conversation_id: Some(1),
conversation_title: Some("Test Conv".into()),
agent_type: "acp".into(),
created_by: "user".into(),
skill_content: None,
description: None,
created_at: now,
updated_at: now,
next_run_at: Some(now + 60_000),
last_run_at: None,
last_status: None,
last_error: None,
run_count: 0,
retry_count: 0,
max_retries: 3,
target_kind: "agent".into(),
terminal_mode: None,
terminal_session_id: None,
terminal_command: None,
terminal_args: None,
terminal_script: None,
}
}
fn make_run(job_id: &str, index: i64) -> CronJobRunRow {
CronJobRunRow {
id: format!("cron_run_{job_id}_{index}"),
job_id: job_id.to_owned(),
executed_at_ms: 1_000 + index,
status: if index % 2 == 0 { "ok" } else { "error" }.to_owned(),
created_at_ms: 2_000 + index,
}
}
#[tokio::test]
async fn insert_run_pruned_keeps_latest_seven_per_job() {
let (repo, _db) = setup().await;
repo.insert(&make_row("cron_runs_a")).await.unwrap();
repo.insert(&make_row("cron_runs_b")).await.unwrap();
for index in 0..10 {
repo.insert_run_pruned(&make_run("cron_runs_a", index))
.await
.unwrap();
}
for index in 0..3 {
repo.insert_run_pruned(&make_run("cron_runs_b", index))
.await
.unwrap();
}
let runs_a = repo.list_runs_by_job("cron_runs_a", 20).await.unwrap();
let runs_b = repo.list_runs_by_job("cron_runs_b", 20).await.unwrap();
assert_eq!(runs_a.len(), 7);
assert_eq!(runs_a[0].executed_at_ms, 1_009);
assert_eq!(runs_a[6].executed_at_ms, 1_003);
assert!(runs_a.iter().all(|run| run.job_id == "cron_runs_a"));
assert_eq!(runs_b.len(), 3);
assert_eq!(runs_b[0].executed_at_ms, 1_002);
assert_eq!(runs_b[2].executed_at_ms, 1_000);
}
#[tokio::test]
async fn insert_and_get_by_id() {
let (repo, _db) = setup().await;
let row = make_row("cron_1");
repo.insert(&row).await.unwrap();
let found = repo.get_by_id("cron_1").await.unwrap().expect("found");
assert_eq!(found.id, "cron_1");
assert_eq!(found.name, "Test Job");
assert!(found.enabled);
assert_eq!(found.schedule_kind, "every");
assert_eq!(found.run_count, 0);
}
#[tokio::test]
async fn get_by_id_returns_none_for_missing() {
let (repo, _db) = setup().await;
let result = repo.get_by_id("cron_missing").await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn list_all_returns_all_rows() {
let (repo, _db) = setup().await;
repo.insert(&make_row("cron_a")).await.unwrap();
repo.insert(&make_row("cron_b")).await.unwrap();
let all = repo.list_all().await.unwrap();
assert_eq!(all.len(), 2);
}
#[tokio::test]
async fn list_enabled_filters_disabled() {
let (repo, _db) = setup().await;
repo.insert(&make_row("cron_e1")).await.unwrap();
let mut disabled = make_row("cron_e2");
disabled.enabled = false;
repo.insert(&disabled).await.unwrap();
let enabled = repo.list_enabled().await.unwrap();
assert_eq!(enabled.len(), 1);
assert_eq!(enabled[0].id, "cron_e1");
}
#[tokio::test]
async fn list_by_conversation_filters_correctly() {
let (repo, db) = setup().await;
sqlx::query(
"INSERT INTO conversations (id, user_id, name, type, created_at, updated_at) \
VALUES (2, 'user_1', 'Other', 'normal', 0, 0)",
)
.execute(db.pool())
.await
.unwrap();
repo.insert(&make_row("cron_c1")).await.unwrap();
let mut other = make_row("cron_c2");
other.conversation_id = Some(2);
repo.insert(&other).await.unwrap();
let conv1_jobs = repo.list_by_conversation(1).await.unwrap();
assert_eq!(conv1_jobs.len(), 1);
assert_eq!(conv1_jobs[0].id, "cron_c1");
let conv2_jobs = repo.list_by_conversation(2).await.unwrap();
assert_eq!(conv2_jobs.len(), 1);
assert_eq!(conv2_jobs[0].id, "cron_c2");
}
#[tokio::test]
async fn update_partial_fields() {
let (repo, _db) = setup().await;
repo.insert(&make_row("cron_u1")).await.unwrap();
let params = UpdateCronJobParams {
name: Some("Renamed".into()),
enabled: Some(false),
run_count: Some(42),
..Default::default()
};
repo.update("cron_u1", &params).await.unwrap();
let updated = repo.get_by_id("cron_u1").await.unwrap().unwrap();
assert_eq!(updated.name, "Renamed");
assert!(!updated.enabled);
assert_eq!(updated.run_count, 42);
assert!(updated.updated_at >= updated.created_at);
}
#[tokio::test]
async fn update_optional_nullable_fields() {
let (repo, _db) = setup().await;
repo.insert(&make_row("cron_u2")).await.unwrap();
let params = UpdateCronJobParams {
last_status: Some(Some("ok".into())),
last_error: Some(Some("timeout".into())),
skill_content: Some(Some("---\nname: skill\n---\nDo it".into())),
..Default::default()
};
repo.update("cron_u2", &params).await.unwrap();
let updated = repo.get_by_id("cron_u2").await.unwrap().unwrap();
assert_eq!(updated.last_status.as_deref(), Some("ok"));
assert_eq!(updated.last_error.as_deref(), Some("timeout"));
assert!(updated.skill_content.is_some());
let clear_params = UpdateCronJobParams {
last_status: Some(None),
last_error: Some(None),
skill_content: Some(None),
..Default::default()
};
repo.update("cron_u2", &clear_params).await.unwrap();
let cleared = repo.get_by_id("cron_u2").await.unwrap().unwrap();
assert!(cleared.last_status.is_none());
assert!(cleared.last_error.is_none());
assert!(cleared.skill_content.is_none());
}
#[tokio::test]
async fn update_nonexistent_returns_not_found() {
let (repo, _db) = setup().await;
let params = UpdateCronJobParams {
name: Some("x".into()),
..Default::default()
};
let err = repo.update("cron_nope", &params).await.unwrap_err();
assert!(matches!(err, DbError::NotFound(_)));
}
#[tokio::test]
async fn update_empty_params_is_noop() {
let (repo, _db) = setup().await;
repo.insert(&make_row("cron_noop")).await.unwrap();
let before = repo.get_by_id("cron_noop").await.unwrap().unwrap();
repo.update("cron_noop", &UpdateCronJobParams::default())
.await
.unwrap();
let after = repo.get_by_id("cron_noop").await.unwrap().unwrap();
assert_eq!(before.updated_at, after.updated_at);
}
#[tokio::test]
async fn delete_removes_row() {
let (repo, _db) = setup().await;
repo.insert(&make_row("cron_d1")).await.unwrap();
repo.delete("cron_d1").await.unwrap();
let result = repo.get_by_id("cron_d1").await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn delete_nonexistent_returns_not_found() {
let (repo, _db) = setup().await;
let err = repo.delete("cron_nope").await.unwrap_err();
assert!(matches!(err, DbError::NotFound(_)));
}
#[tokio::test]
async fn delete_by_conversation_removes_all_related() {
let (repo, _db) = setup().await;
repo.insert(&make_row("cron_dc1")).await.unwrap();
repo.insert(&make_row("cron_dc2")).await.unwrap();
let deleted = repo.delete_by_conversation(1).await.unwrap();
assert_eq!(deleted, 2);
let remaining = repo.list_all().await.unwrap();
assert!(remaining.is_empty());
}
#[tokio::test]
async fn delete_by_conversation_returns_zero_for_no_match() {
let (repo, _db) = setup().await;
let deleted = repo.delete_by_conversation(999).await.unwrap();
assert_eq!(deleted, 0);
}
#[tokio::test]
async fn update_schedule_fields() {
let (repo, _db) = setup().await;
repo.insert(&make_row("cron_s1")).await.unwrap();
let params = UpdateCronJobParams {
schedule_kind: Some("cron".into()),
schedule_value: Some("0 0 9 * * *".into()),
schedule_tz: Some(Some("Asia/Shanghai".into())),
schedule_description: Some(Some("Daily at 9am".into())),
next_run_at: Some(Some(9999999)),
..Default::default()
};
repo.update("cron_s1", &params).await.unwrap();
let updated = repo.get_by_id("cron_s1").await.unwrap().unwrap();
assert_eq!(updated.schedule_kind, "cron");
assert_eq!(updated.schedule_value, "0 0 9 * * *");
assert_eq!(updated.schedule_tz.as_deref(), Some("Asia/Shanghai"));
assert_eq!(updated.next_run_at, Some(9999999));
}
#[tokio::test]
async fn insert_all_schedule_kinds() {
let (repo, _db) = setup().await;
let mut at_job = make_row("cron_at");
at_job.schedule_kind = "at".into();
at_job.schedule_value = "1700000000000".into();
repo.insert(&at_job).await.unwrap();
let mut cron_job = make_row("cron_cron");
cron_job.schedule_kind = "cron".into();
cron_job.schedule_value = "0 */5 * * * *".into();
cron_job.schedule_tz = Some("UTC".into());
repo.insert(&cron_job).await.unwrap();
let all = repo.list_all().await.unwrap();
assert_eq!(all.len(), 2);
}
#[tokio::test]
async fn insert_with_skill_content() {
let (repo, _db) = setup().await;
let mut row = make_row("cron_sk");
row.skill_content = Some("---\nname: My Skill\ndescription: A test\n---\nDo X".into());
repo.insert(&row).await.unwrap();
let found = repo.get_by_id("cron_sk").await.unwrap().unwrap();
assert!(found.skill_content.unwrap().contains("My Skill"));
}
#[tokio::test]
async fn insert_with_agent_config_json() {
let (repo, _db) = setup().await;
let mut row = make_row("cron_ac");
row.agent_config = Some(r#"{"backend":"openai","name":"GPT","modelId":"gpt-4"}"#.into());
repo.insert(&row).await.unwrap();
let found = repo.get_by_id("cron_ac").await.unwrap().unwrap();
let config = found.agent_config.unwrap();
assert!(config.contains("openai"));
assert!(config.contains("gpt-4"));
}
}
@@ -0,0 +1,174 @@
use sqlx::SqlitePool;
use crate::error::DbError;
use crate::models::{DomainConfigRow, DomainPreset, DomainPresetRow, UpdateDomainConfigParams};
use crate::repository::IDomainConfigRepository;
/// SQLite-backed implementation of [`IDomainConfigRepository`].
#[derive(Clone, Debug)]
pub struct SqliteDomainConfigRepository {
pool: SqlitePool,
}
impl SqliteDomainConfigRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
/// Fetch the single domain_config row (id=1).
async fn fetch_row(&self) -> Result<Option<DomainConfigRow>, DbError> {
sqlx::query_as::<_, DomainConfigRow>(
"SELECT id, domain_type, settings, government_settings, \
enterprise_settings, education_settings, enabled_features, \
departments, custom_params, created_at, updated_at \
FROM domain_config WHERE id = 1",
)
.fetch_optional(&self.pool)
.await
.map_err(DbError::Query)
}
/// Fetch a domain preset row by ID.
async fn fetch_preset(&self, preset_id: &str) -> Result<Option<DomainPreset>, DbError> {
let row: Option<DomainPresetRow> = sqlx::query_as(
"SELECT id, name, domain_type, description, settings, config, sort_order, created_at \
FROM domain_presets WHERE id = ?",
)
.bind(preset_id)
.fetch_optional(&self.pool)
.await
.map_err(DbError::Query)?;
Ok(row.map(|r| r.into()))
}
/// Fetch all domain preset rows, ordered by sort_order.
async fn fetch_all_presets(&self) -> Result<Vec<DomainPreset>, DbError> {
let rows: Vec<DomainPresetRow> = sqlx::query_as(
"SELECT id, name, domain_type, description, settings, config, sort_order, created_at \
FROM domain_presets ORDER BY sort_order ASC",
)
.fetch_all(&self.pool)
.await
.map_err(DbError::Query)?;
Ok(rows.into_iter().map(|r| r.into()).collect())
}
}
#[async_trait::async_trait]
impl IDomainConfigRepository for SqliteDomainConfigRepository {
async fn get_config(&self) -> Result<Option<DomainConfigRow>, DbError> {
self.fetch_row().await
}
async fn update_config(&self, params: UpdateDomainConfigParams) -> Result<DomainConfigRow, DbError> {
let now = nomifun_common::now_ms();
let mut set_parts = Vec::new();
if params.settings.is_some() {
set_parts.push("settings = ?");
}
if params.government_settings.is_some() {
set_parts.push("government_settings = ?");
}
if params.enterprise_settings.is_some() {
set_parts.push("enterprise_settings = ?");
}
if params.education_settings.is_some() {
set_parts.push("education_settings = ?");
}
if params.enabled_features.is_some() {
set_parts.push("enabled_features = ?");
}
if params.departments.is_some() {
set_parts.push("departments = ?");
}
if params.custom_params.is_some() {
set_parts.push("custom_params = ?");
}
if set_parts.is_empty() {
return self
.fetch_row()
.await?
.ok_or_else(|| DbError::NotFound("domain_config row not found".into()));
}
set_parts.push("updated_at = ?");
let query = format!("UPDATE domain_config SET {} WHERE id = 1", set_parts.join(", "));
let mut q = sqlx::query(&query);
if let Some(ref v) = params.settings {
q = q.bind(v);
}
if let Some(ref v) = params.government_settings {
q = q.bind(v);
}
if let Some(ref v) = params.enterprise_settings {
q = q.bind(v);
}
if let Some(ref v) = params.education_settings {
q = q.bind(v);
}
if let Some(ref v) = params.enabled_features {
q = q.bind(v);
}
if let Some(ref v) = params.departments {
q = q.bind(v);
}
if let Some(ref v) = params.custom_params {
q = q.bind(v);
}
q = q.bind(now);
q.execute(&self.pool).await.map_err(DbError::Query)?;
self.fetch_row()
.await?
.ok_or_else(|| DbError::NotFound("domain_config row not found".into()))
}
async fn get_presets(&self) -> Result<Vec<DomainPreset>, DbError> {
self.fetch_all_presets().await
}
async fn apply_preset(&self, preset_id: &str) -> Result<DomainConfigRow, DbError> {
let preset = self
.fetch_preset(preset_id)
.await?
.ok_or_else(|| DbError::NotFound(format!("Domain preset '{}' not found", preset_id)))?;
let now = nomifun_common::now_ms();
let settings_json =
serde_json::to_string(&preset.settings).map_err(|e| DbError::Init(e.to_string()))?;
let departments_json =
serde_json::to_string(&preset.departments).map_err(|e| DbError::Init(e.to_string()))?;
let features_json =
serde_json::to_string(&preset.features).map_err(|e| DbError::Init(e.to_string()))?;
sqlx::query(
"UPDATE domain_config SET \
domain_type = ?, \
settings = ?, \
enabled_features = ?, \
departments = ?, \
updated_at = ? \
WHERE id = 1",
)
.bind(&preset.domain_type)
.bind(&settings_json)
.bind(&features_json)
.bind(&departments_json)
.bind(now)
.execute(&self.pool)
.await
.map_err(DbError::Query)?;
self.fetch_row()
.await?
.ok_or_else(|| DbError::NotFound("domain_config row not found".into()))
}
}
@@ -0,0 +1,293 @@
use sqlx::SqlitePool;
use crate::error::DbError;
use crate::models::IdmmInterventionRow;
use crate::repository::idmm_intervention::{IIdmmInterventionRepository, PER_TARGET_CAP};
#[derive(Clone, Debug)]
pub struct SqliteIdmmInterventionRepository {
pool: SqlitePool,
}
impl SqliteIdmmInterventionRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait::async_trait]
impl IIdmmInterventionRepository for SqliteIdmmInterventionRepository {
async fn insert(&self, row: &IdmmInterventionRow) -> Result<(), DbError> {
sqlx::query(
"INSERT INTO idmm_interventions (\
id, target_kind, target_id, watch, at, signal, tier_used, category, \
action, detail, reason, confidence, bypass_model, outcome\
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&row.id)
.bind(&row.target_kind)
.bind(&row.target_id)
.bind(&row.watch)
.bind(row.at)
.bind(&row.signal)
.bind(&row.tier_used)
.bind(&row.category)
.bind(&row.action)
.bind(&row.detail)
.bind(&row.reason)
.bind(row.confidence)
.bind(&row.bypass_model)
.bind(&row.outcome)
.execute(&self.pool)
.await?;
// 激进淘汰:每写入即把该 target 裁到最近 PER_TARGET_CAP 条(数据可丢)。
sqlx::query(
"DELETE FROM idmm_interventions \
WHERE target_kind = ?1 AND target_id = ?2 \
AND id NOT IN (\
SELECT id FROM idmm_interventions \
WHERE target_kind = ?1 AND target_id = ?2 \
ORDER BY at DESC, id DESC LIMIT ?3\
)",
)
.bind(&row.target_kind)
.bind(&row.target_id)
.bind(PER_TARGET_CAP)
.execute(&self.pool)
.await?;
Ok(())
}
async fn list_for_target(
&self,
target_kind: &str,
target_id: &str,
limit: i64,
) -> Result<Vec<IdmmInterventionRow>, DbError> {
let rows = sqlx::query_as::<_, IdmmInterventionRow>(
"SELECT * FROM idmm_interventions \
WHERE target_kind = ? AND target_id = ? \
ORDER BY at DESC, id DESC LIMIT ?",
)
.bind(target_kind)
.bind(target_id)
.bind(limit)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
async fn delete_for_target(&self, target_kind: &str, target_id: &str) -> Result<u64, DbError> {
let result = sqlx::query("DELETE FROM idmm_interventions WHERE target_kind = ? AND target_id = ?")
.bind(target_kind)
.bind(target_id)
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
async fn list_recent(&self, limit: i64) -> Result<Vec<IdmmInterventionRow>, DbError> {
let rows = sqlx::query_as::<_, IdmmInterventionRow>(
"SELECT * FROM idmm_interventions ORDER BY at DESC, id DESC LIMIT ?",
)
.bind(limit)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
async fn clear_all(&self) -> Result<u64, DbError> {
let result = sqlx::query("DELETE FROM idmm_interventions").execute(&self.pool).await?;
Ok(result.rows_affected())
}
async fn sweep(&self, cutoff_ms: i64, global_cap: i64) -> Result<u64, DbError> {
// 先按 TTL 删旧。
let by_ttl = sqlx::query("DELETE FROM idmm_interventions WHERE at < ?")
.bind(cutoff_ms)
.execute(&self.pool)
.await?
.rows_affected();
// 再按全局硬上限兜底:只留最近 global_cap 条。
let by_cap = sqlx::query(
"DELETE FROM idmm_interventions \
WHERE id NOT IN (\
SELECT id FROM idmm_interventions ORDER BY at DESC, id DESC LIMIT ?\
)",
)
.bind(global_cap)
.execute(&self.pool)
.await?
.rows_affected();
Ok(by_ttl + by_cap)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::init_database_memory;
async fn setup() -> (SqliteIdmmInterventionRepository, crate::Database) {
let db = init_database_memory().await.unwrap();
let repo = SqliteIdmmInterventionRepository::new(db.pool().clone());
(repo, db)
}
fn sample_row(id: &str, target_kind: &str, target_id: &str, at: i64) -> IdmmInterventionRow {
IdmmInterventionRow {
id: id.to_string(),
target_kind: target_kind.to_string(),
target_id: target_id.to_string(),
watch: "decision".to_string(),
at,
signal: "decision".to_string(),
tier_used: "rule".to_string(),
category: Some("option".to_string()),
action: "answer_choice".to_string(),
detail: Some("选了方案A".to_string()),
reason: Some("规则匹配".to_string()),
confidence: None,
bypass_model: None,
outcome: "applied".to_string(),
}
}
#[tokio::test]
async fn insert_then_list_returns_recent_first() {
let (repo, _db) = setup().await;
repo.insert(&sample_row("idmmrec_a", "conversation", "c1", 10))
.await
.unwrap();
repo.insert(&sample_row("idmmrec_b", "conversation", "c1", 30))
.await
.unwrap();
repo.insert(&sample_row("idmmrec_c", "conversation", "c1", 20))
.await
.unwrap();
let rows = repo.list_for_target("conversation", "c1", 100).await.unwrap();
let ids: Vec<&str> = rows.iter().map(|r| r.id.as_str()).collect();
// 按 at DESC:30 -> 20 -> 10。
assert_eq!(ids, vec!["idmmrec_b", "idmmrec_c", "idmmrec_a"]);
}
#[tokio::test]
async fn insert_prunes_to_per_target_cap() {
let (repo, _db) = setup().await;
// 插 35 条,at 递增(at=i 对应 id idmmrec_i)。
for i in 0..35 {
repo.insert(&sample_row(&format!("idmmrec_{i:02}"), "conversation", "c1", i))
.await
.unwrap();
}
let rows = repo.list_for_target("conversation", "c1", 100).await.unwrap();
assert_eq!(rows.len(), PER_TARGET_CAP as usize);
assert_eq!(rows.len(), 30);
// 最旧 5 条(at 0..=4)应已被裁掉。
let ids: Vec<String> = rows.iter().map(|r| r.id.clone()).collect();
for i in 0..5 {
let stale = format!("idmmrec_{i:02}");
assert!(!ids.contains(&stale), "oldest id {stale} should have been evicted");
}
// 最新一条仍在。
assert!(ids.contains(&"idmmrec_34".to_string()));
// 最旧的留存项是 at=5。
let oldest = rows.last().unwrap();
assert_eq!(oldest.id, "idmmrec_05");
}
#[tokio::test]
async fn delete_for_target_removes_only_that_target() {
let (repo, _db) = setup().await;
repo.insert(&sample_row("idmmrec_c1a", "conversation", "c1", 10))
.await
.unwrap();
repo.insert(&sample_row("idmmrec_c1b", "conversation", "c1", 20))
.await
.unwrap();
repo.insert(&sample_row("idmmrec_t1a", "terminal", "1", 15))
.await
.unwrap();
let removed = repo.delete_for_target("conversation", "c1").await.unwrap();
assert_eq!(removed, 2);
assert!(repo.list_for_target("conversation", "c1", 100).await.unwrap().is_empty());
let remaining = repo.list_for_target("terminal", "1", 100).await.unwrap();
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].id, "idmmrec_t1a");
}
#[tokio::test]
async fn sweep_removes_older_than_cutoff() {
let (repo, _db) = setup().await;
repo.insert(&sample_row("idmmrec_old", "conversation", "c1", 100))
.await
.unwrap();
repo.insert(&sample_row("idmmrec_new", "conversation", "c1", 1000))
.await
.unwrap();
// cutoff=500:删 at<500(old),留 new。global_cap 足够大不触发硬上限。
let removed = repo.sweep(500, 2000).await.unwrap();
assert_eq!(removed, 1);
let rows = repo.list_for_target("conversation", "c1", 100).await.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].id, "idmmrec_new");
}
#[tokio::test]
async fn list_recent_is_cross_target_recent_first_capped() {
let (repo, _db) = setup().await;
// 跨多个 target 写入,at 交错。
repo.insert(&sample_row("idmmrec_c1a", "conversation", "c1", 10))
.await
.unwrap();
repo.insert(&sample_row("idmmrec_t1a", "terminal", "1", 40))
.await
.unwrap();
repo.insert(&sample_row("idmmrec_c2a", "conversation", "c2", 20))
.await
.unwrap();
repo.insert(&sample_row("idmmrec_t1b", "terminal", "1", 30))
.await
.unwrap();
// 跨全部 target 按 at DESC:40 -> 30 -> 20 -> 10。
let rows = repo.list_recent(100).await.unwrap();
let ids: Vec<&str> = rows.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, vec!["idmmrec_t1a", "idmmrec_t1b", "idmmrec_c2a", "idmmrec_c1a"]);
// limit 封顶,仍取最近的。
let capped = repo.list_recent(2).await.unwrap();
let ids: Vec<&str> = capped.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, vec!["idmmrec_t1a", "idmmrec_t1b"]);
}
#[tokio::test]
async fn clear_all_empties_table_and_returns_count() {
let (repo, _db) = setup().await;
repo.insert(&sample_row("idmmrec_c1a", "conversation", "c1", 10))
.await
.unwrap();
repo.insert(&sample_row("idmmrec_t1a", "terminal", "1", 20))
.await
.unwrap();
repo.insert(&sample_row("idmmrec_c2a", "conversation", "c2", 30))
.await
.unwrap();
let removed = repo.clear_all().await.unwrap();
assert_eq!(removed, 3);
assert!(repo.list_recent(100).await.unwrap().is_empty());
}
}
@@ -0,0 +1,732 @@
use sqlx::SqlitePool;
use crate::error::DbError;
use crate::models::{CreateKnowledgeTagParams, KnowledgeBaseRow, KnowledgeBindingRow, KnowledgeTagRow, UpdateKnowledgeTagParams};
use crate::repository::knowledge::IKnowledgeRepository;
#[derive(Clone, Debug)]
pub struct SqliteKnowledgeRepository {
pool: SqlitePool,
}
impl SqliteKnowledgeRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
/// Map a binding `target_kind` to the `knowledge_bindings` column that carries
/// its `target_id`. Returns `None` for an unrecognized kind so callers can
/// reject it without risking a write to the wrong column.
fn target_column(target_kind: &str) -> Option<&'static str> {
match target_kind {
"workpath" => Some("target_workpath"),
"conversation" => Some("target_conv_id"),
"terminal" => Some("target_term_id"),
"companion" => Some("target_companion_id"),
_ => None,
}
}
/// Whether the target column for `target_kind` is an INTEGER FK column
/// (conversation/terminal) rather than a TEXT column (workpath/companion). The dual-
/// domain `target_id` arrives as a string at the trait boundary; for the integer
/// kinds it is parsed before binding so SQLite stores a true integer and the FK
/// to conversations/terminal_sessions resolves.
fn target_is_integer(target_kind: &str) -> bool {
matches!(target_kind, "conversation" | "terminal")
}
/// Bind `target_id` to a raw query as either an integer (conv/terminal) or text
/// (workpath/companion), matching the column's affinity. A non-numeric id for an
/// integer kind binds NULL, which simply matches/affects no rows.
fn bind_target_id<'q>(
query: sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>>,
target_kind: &str,
target_id: &'q str,
) -> sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>> {
if target_is_integer(target_kind) {
query.bind(target_id.parse::<i64>().ok())
} else {
query.bind(target_id)
}
}
fn bind_target_id_as<'q, T>(
query: sqlx::query::QueryAs<'q, sqlx::Sqlite, T, sqlx::sqlite::SqliteArguments<'q>>,
target_kind: &str,
target_id: &'q str,
) -> sqlx::query::QueryAs<'q, sqlx::Sqlite, T, sqlx::sqlite::SqliteArguments<'q>> {
if target_is_integer(target_kind) {
query.bind(target_id.parse::<i64>().ok())
} else {
query.bind(target_id)
}
}
fn bind_target_id_scalar<'q, T>(
query: sqlx::query::QueryScalar<'q, sqlx::Sqlite, T, sqlx::sqlite::SqliteArguments<'q>>,
target_kind: &str,
target_id: &'q str,
) -> sqlx::query::QueryScalar<'q, sqlx::Sqlite, T, sqlx::sqlite::SqliteArguments<'q>> {
if target_is_integer(target_kind) {
query.bind(target_id.parse::<i64>().ok())
} else {
query.bind(target_id)
}
}
#[async_trait::async_trait]
impl IKnowledgeRepository for SqliteKnowledgeRepository {
async fn insert_base(&self, row: &KnowledgeBaseRow) -> Result<(), DbError> {
sqlx::query(
"INSERT INTO knowledge_bases (\
id, name, description, root_path, managed, extra, created_at, updated_at, tags\
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&row.id)
.bind(&row.name)
.bind(&row.description)
.bind(&row.root_path)
.bind(row.managed)
.bind(&row.extra)
.bind(row.created_at)
.bind(row.updated_at)
.bind(&row.tags)
.execute(&self.pool)
.await?;
Ok(())
}
async fn update_base(&self, row: &KnowledgeBaseRow) -> Result<(), DbError> {
let result = sqlx::query(
"UPDATE knowledge_bases SET name = ?, description = ?, extra = ?, tags = ?, updated_at = ? WHERE id = ?",
)
.bind(&row.name)
.bind(&row.description)
.bind(&row.extra)
.bind(&row.tags)
.bind(row.updated_at)
.bind(&row.id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(DbError::NotFound(format!("knowledge base {}", row.id)));
}
Ok(())
}
async fn delete_base(&self, id: &str) -> Result<(), DbError> {
let result = sqlx::query("DELETE FROM knowledge_bases WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(DbError::NotFound(format!("knowledge base {id}")));
}
Ok(())
}
async fn get_base(&self, id: &str) -> Result<Option<KnowledgeBaseRow>, DbError> {
let row = sqlx::query_as::<_, KnowledgeBaseRow>("SELECT * FROM knowledge_bases WHERE id = ?")
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
async fn list_bases(&self) -> Result<Vec<KnowledgeBaseRow>, DbError> {
let rows = sqlx::query_as::<_, KnowledgeBaseRow>("SELECT * FROM knowledge_bases ORDER BY created_at ASC")
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
async fn get_binding(
&self,
target_kind: &str,
target_id: &str,
) -> Result<Option<(KnowledgeBindingRow, Vec<String>)>, DbError> {
let Some(column) = target_column(target_kind) else {
return Ok(None);
};
// The kind is fixed to a static column name above, never user input,
// so this format! cannot inject. Also filter on target_kind so a stray
// value in the wrong column can never satisfy the lookup.
let sql = format!(
"SELECT * FROM knowledge_bindings WHERE target_kind = ? AND {column} = ?"
);
let row = sqlx::query_as::<_, KnowledgeBindingRow>(&sql)
.bind(target_kind);
let row = bind_target_id_as(row, target_kind, target_id)
.fetch_optional(&self.pool)
.await?;
let Some(row) = row else {
return Ok(None);
};
let kb_ids = self.fetch_kb_ids(row.binding_id).await?;
Ok(Some((row, kb_ids)))
}
async fn set_binding(
&self,
target_kind: &str,
target_id: &str,
kb_ids: &[String],
enabled: bool,
writeback: bool,
writeback_mode: &str,
writeback_eagerness: &str,
channel_write_enabled: bool,
updated_at: nomifun_common::TimestampMs,
) -> Result<i64, DbError> {
let Some(column) = target_column(target_kind) else {
return Err(DbError::NotFound(format!(
"unknown knowledge binding kind {target_kind}"
)));
};
let mut tx = self.pool.begin().await?;
// 1. Upsert the main row. SELECT the existing binding_id via the typed
// target column (each kind has a partial UNIQUE index, so at most
// one row matches), then UPDATE or INSERT accordingly.
let select_sql = format!(
"SELECT binding_id FROM knowledge_bindings WHERE target_kind = ? AND {column} = ?"
);
let existing: Option<i64> = bind_target_id_scalar(
sqlx::query_scalar(&select_sql).bind(target_kind),
target_kind,
target_id,
)
.fetch_optional(&mut *tx)
.await?;
let binding_id = if let Some(binding_id) = existing {
sqlx::query(
"UPDATE knowledge_bindings \
SET enabled = ?, writeback = ?, writeback_mode = ?, writeback_eagerness = ?, \
channel_write_enabled = ?, updated_at = ? \
WHERE binding_id = ?",
)
.bind(enabled)
.bind(writeback)
.bind(writeback_mode)
.bind(writeback_eagerness)
.bind(channel_write_enabled)
.bind(updated_at)
.bind(binding_id)
.execute(&mut *tx)
.await?;
binding_id
} else {
// The other three target columns stay NULL; the CHECK enforces
// exactly-one-non-null matching target_kind.
let insert_sql = format!(
"INSERT INTO knowledge_bindings \
(target_kind, {column}, enabled, writeback, writeback_mode, writeback_eagerness, \
channel_write_enabled, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
);
let result = bind_target_id(
sqlx::query(&insert_sql).bind(target_kind),
target_kind,
target_id,
)
.bind(enabled)
.bind(writeback)
.bind(writeback_mode)
.bind(writeback_eagerness)
.bind(channel_write_enabled)
.bind(updated_at)
.execute(&mut *tx)
.await?;
result.last_insert_rowid()
};
// 2. Replace the junction rows for this binding, preserving kb_ids order.
sqlx::query("DELETE FROM knowledge_binding_bases WHERE binding_id = ?")
.bind(binding_id)
.execute(&mut *tx)
.await?;
for (position, kb_id) in kb_ids.iter().enumerate() {
sqlx::query(
"INSERT INTO knowledge_binding_bases (binding_id, kb_id, position) VALUES (?, ?, ?)",
)
.bind(binding_id)
.bind(kb_id)
.bind(position as i64)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(binding_id)
}
async fn delete_binding(&self, target_kind: &str, target_id: &str) -> Result<(), DbError> {
let Some(column) = target_column(target_kind) else {
return Ok(());
};
// The junction rows are removed by FK CASCADE on knowledge_binding_bases.
let sql = format!(
"DELETE FROM knowledge_bindings WHERE target_kind = ? AND {column} = ?"
);
bind_target_id(sqlx::query(&sql).bind(target_kind), target_kind, target_id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn list_bindings_using_kb(&self, kb_id: &str) -> Result<Vec<KnowledgeBindingRow>, DbError> {
let rows = sqlx::query_as::<_, KnowledgeBindingRow>(
"SELECT b.* FROM knowledge_bindings b \
JOIN knowledge_binding_bases j ON j.binding_id = b.binding_id \
WHERE j.kb_id = ? \
ORDER BY b.target_kind ASC, b.binding_id ASC",
)
.bind(kb_id)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
// ── Knowledge tags ────────────────────────────────────────────────────
async fn list_knowledge_tags(&self) -> Result<Vec<KnowledgeTagRow>, DbError> {
let rows = sqlx::query_as::<_, KnowledgeTagRow>(
"SELECT * FROM knowledge_tags ORDER BY sort_order ASC, key ASC",
)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
async fn create_knowledge_tag(&self, params: CreateKnowledgeTagParams) -> Result<(), DbError> {
sqlx::query(
"INSERT INTO knowledge_tags (key, label, color, sort_order, created_at) VALUES (?, ?, ?, ?, ?)",
)
.bind(&params.key)
.bind(&params.label)
.bind(&params.color)
.bind(params.sort_order)
.bind(params.created_at)
.execute(&self.pool)
.await?;
Ok(())
}
async fn update_knowledge_tag(&self, key: &str, params: UpdateKnowledgeTagParams) -> Result<(), DbError> {
// Build a dynamic SET clause from the provided fields.
let mut sets: Vec<&str> = Vec::new();
if params.label.is_some() {
sets.push("label = ?");
}
if params.color.is_some() {
sets.push("color = ?");
}
if params.sort_order.is_some() {
sets.push("sort_order = ?");
}
if sets.is_empty() {
// Nothing to update; verify the key exists.
let exists = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM knowledge_tags WHERE key = ?",
)
.bind(key)
.fetch_one(&self.pool)
.await?;
if exists == 0 {
return Err(DbError::NotFound(format!("knowledge tag {key}")));
}
return Ok(());
}
let sql = format!("UPDATE knowledge_tags SET {} WHERE key = ?", sets.join(", "));
let mut query = sqlx::query(&sql);
if let Some(ref label) = params.label {
query = query.bind(label);
}
if let Some(ref color) = params.color {
query = query.bind(color.as_deref());
}
if let Some(sort_order) = params.sort_order {
query = query.bind(sort_order);
}
query = query.bind(key);
let result = query.execute(&self.pool).await?;
if result.rows_affected() == 0 {
return Err(DbError::NotFound(format!("knowledge tag {key}")));
}
Ok(())
}
async fn delete_knowledge_tag(&self, key: &str) -> Result<(), DbError> {
let result = sqlx::query("DELETE FROM knowledge_tags WHERE key = ?")
.bind(key)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(DbError::NotFound(format!("knowledge tag {key}")));
}
Ok(())
}
}
impl SqliteKnowledgeRepository {
/// Reassemble a binding's `kb_id` list from the junction, ordered by
/// `position` (the original `kb_ids` array order).
async fn fetch_kb_ids(&self, binding_id: i64) -> Result<Vec<String>, DbError> {
let kb_ids = sqlx::query_scalar::<_, String>(
"SELECT kb_id FROM knowledge_binding_bases WHERE binding_id = ? ORDER BY position ASC, kb_id ASC",
)
.bind(binding_id)
.fetch_all(&self.pool)
.await?;
Ok(kb_ids)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::init_database_memory;
fn make_base(id: &str) -> KnowledgeBaseRow {
KnowledgeBaseRow {
id: id.into(),
name: format!("kb-{id}"),
description: String::new(),
root_path: format!("/tmp/{id}"),
managed: true,
extra: "{}".into(),
created_at: 1,
updated_at: 1,
tags: None,
}
}
#[tokio::test]
async fn base_crud_roundtrip() {
let db = init_database_memory().await.unwrap();
let repo = SqliteKnowledgeRepository::new(db.pool().clone());
repo.insert_base(&make_base("kb_a")).await.unwrap();
repo.insert_base(&make_base("kb_b")).await.unwrap();
assert_eq!(repo.list_bases().await.unwrap().len(), 2);
let mut row = repo.get_base("kb_a").await.unwrap().unwrap();
row.name = "renamed".into();
// `extra` is mutable through update (URL-source config lives there).
row.extra = r#"{"source":{"kind":"url","mode":"live"}}"#.into();
row.updated_at = 2;
repo.update_base(&row).await.unwrap();
let got = repo.get_base("kb_a").await.unwrap().unwrap();
assert_eq!(got.name, "renamed");
assert_eq!(got.extra, r#"{"source":{"kind":"url","mode":"live"}}"#);
repo.delete_base("kb_a").await.unwrap();
assert!(repo.get_base("kb_a").await.unwrap().is_none());
assert!(matches!(repo.delete_base("kb_a").await, Err(DbError::NotFound(_))));
}
/// Insert a conversation so the conversation-kind binding's FK + CHECK are
/// satisfied (target_conv_id REFERENCES conversations(id) ON DELETE CASCADE).
/// The explicit integer id is a valid AUTOINCREMENT rowid.
async fn seed_conversation(pool: &SqlitePool, id: i64) {
// `system_default_user` is seeded by ensure_system_user() in
// init_database_memory, satisfying conversations.user_id FK.
sqlx::query(
"INSERT INTO conversations (id, user_id, name, type, status, created_at, updated_at) \
VALUES (?, 'system_default_user', 'c', 'gemini', 'pending', 1, 1)",
)
.bind(id)
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn binding_set_get_roundtrip() {
let db = init_database_memory().await.unwrap();
let repo = SqliteKnowledgeRepository::new(db.pool().clone());
seed_conversation(db.pool(), 1).await;
repo.insert_base(&make_base("kb_a")).await.unwrap();
repo.insert_base(&make_base("kb_b")).await.unwrap();
assert!(repo.get_binding("conversation", "1").await.unwrap().is_none());
// Initial set: one base, disabled writeback, staged mode.
let id1 = repo
.set_binding(
"conversation",
"1",
&["kb_a".to_owned()],
true,
false,
"staged",
"conservative",
false,
1,
)
.await
.unwrap();
assert!(id1 > 0);
let (row, kb_ids) = repo.get_binding("conversation", "1").await.unwrap().unwrap();
assert_eq!(row.binding_id, id1);
assert_eq!(row.target_kind, "conversation");
assert_eq!(row.target_id(), Some("1".to_string()));
assert_eq!(row.target_conv_id, Some(1));
assert!(row.target_workpath.is_none() && row.target_term_id.is_none() && row.target_companion_id.is_none());
assert!(row.enabled);
assert!(!row.writeback);
assert_eq!(row.writeback_mode, "staged");
assert_eq!(row.writeback_eagerness, "conservative");
assert_eq!(kb_ids, vec!["kb_a".to_owned()]);
// Update: same target reuses binding_id; junction replaced + reordered.
let id2 = repo
.set_binding(
"conversation",
"1",
&["kb_b".to_owned(), "kb_a".to_owned()],
true,
true,
"direct",
"aggressive",
false,
2,
)
.await
.unwrap();
assert_eq!(id2, id1, "same target must reuse the surrogate binding_id");
let (row, kb_ids) = repo.get_binding("conversation", "1").await.unwrap().unwrap();
assert!(row.writeback);
assert_eq!(row.writeback_mode, "direct");
assert_eq!(row.writeback_eagerness, "aggressive");
assert_eq!(row.updated_at, 2);
// Order from kb_ids slice is preserved via position.
assert_eq!(kb_ids, vec!["kb_b".to_owned(), "kb_a".to_owned()]);
repo.delete_binding("conversation", "1").await.unwrap();
assert!(repo.get_binding("conversation", "1").await.unwrap().is_none());
// Deleting an absent binding is a no-op, not an error.
repo.delete_binding("conversation", "1").await.unwrap();
}
/// A workpath binding is keyed by a path string (not an entity, no FK), so
/// it exercises the non-FK target column + the workpath partial UNIQUE.
#[tokio::test]
async fn binding_workpath_kind_and_empty_kb_ids() {
let db = init_database_memory().await.unwrap();
let repo = SqliteKnowledgeRepository::new(db.pool().clone());
let bid = repo
.set_binding("workpath", "/work/proj", &[], false, false, "staged", "conservative", false, 5)
.await
.unwrap();
assert!(bid > 0);
let (row, kb_ids) = repo.get_binding("workpath", "/work/proj").await.unwrap().unwrap();
assert_eq!(row.target_kind, "workpath");
assert_eq!(row.target_workpath.as_deref(), Some("/work/proj"));
assert!(row.target_conv_id.is_none());
assert!(!row.enabled);
assert!(kb_ids.is_empty(), "empty kb_ids slice yields no junction rows");
// A different target_id is an independent binding.
assert!(repo.get_binding("workpath", "/other").await.unwrap().is_none());
}
/// Deleting the conversation cascades the binding row away (target_conv_id
/// FK ON DELETE CASCADE), and the junction follows via its own CASCADE.
#[tokio::test]
async fn deleting_conversation_cascades_binding() {
let db = init_database_memory().await.unwrap();
let repo = SqliteKnowledgeRepository::new(db.pool().clone());
seed_conversation(db.pool(), 9).await;
repo.insert_base(&make_base("kb_a")).await.unwrap();
let bid = repo
.set_binding("conversation", "9", &["kb_a".to_owned()], true, false, "staged", "conservative", false, 1)
.await
.unwrap();
sqlx::query("DELETE FROM conversations WHERE id = ?")
.bind(9_i64)
.execute(db.pool())
.await
.unwrap();
assert!(repo.get_binding("conversation", "9").await.unwrap().is_none());
let orphans = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM knowledge_binding_bases WHERE binding_id = ?",
)
.bind(bid)
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(orphans, 0, "junction rows must cascade with the binding");
}
/// An unknown kind is rejected on write and resolves to None on read,
/// never silently writing to or matching the wrong column.
#[tokio::test]
async fn unknown_kind_is_rejected() {
let db = init_database_memory().await.unwrap();
let repo = SqliteKnowledgeRepository::new(db.pool().clone());
assert!(matches!(
repo.set_binding("bogus", "x", &[], true, false, "staged", "conservative", false, 1).await,
Err(DbError::NotFound(_))
));
assert!(repo.get_binding("bogus", "x").await.unwrap().is_none());
// delete of an unknown kind is a no-op.
repo.delete_binding("bogus", "x").await.unwrap();
}
/// `channel_write_enabled` (migration 009) persists and updates.
#[tokio::test]
async fn binding_persists_channel_write_enabled() {
let db = init_database_memory().await.unwrap();
let repo = SqliteKnowledgeRepository::new(db.pool().clone());
repo.insert_base(&make_base("kb_a")).await.unwrap();
// Default (false) on a write without the flag set.
repo.set_binding("workpath", "/wp", &["kb_a".to_owned()], true, true, "staged", "conservative", false, 1)
.await
.unwrap();
let (row, _) = repo.get_binding("workpath", "/wp").await.unwrap().unwrap();
assert!(!row.channel_write_enabled);
// Re-enable on update.
repo.set_binding("workpath", "/wp", &["kb_a".to_owned()], true, true, "staged", "conservative", true, 2)
.await
.unwrap();
let (row, _) = repo.get_binding("workpath", "/wp").await.unwrap().unwrap();
assert!(row.channel_write_enabled, "channel_write_enabled must persist + update");
}
#[tokio::test]
async fn list_bindings_using_kb_returns_all_consumers() {
let db = init_database_memory().await.unwrap();
let repo = SqliteKnowledgeRepository::new(db.pool().clone());
repo.insert_base(&make_base("kb_a")).await.unwrap();
repo.insert_base(&make_base("kb_b")).await.unwrap();
// Two workpath bindings use kb_a (one enabled, one disabled); one uses kb_b only.
repo.set_binding("workpath", "/p1", &["kb_a".to_owned()], true, false, "staged", "conservative", false, 1)
.await
.unwrap();
repo.set_binding("workpath", "/p2", &["kb_a".to_owned(), "kb_b".to_owned()], false, false, "staged", "conservative", false, 1)
.await
.unwrap();
repo.set_binding("workpath", "/p3", &["kb_b".to_owned()], true, false, "staged", "conservative", false, 1)
.await
.unwrap();
let mut using_a = repo.list_bindings_using_kb("kb_a").await.unwrap();
assert_eq!(using_a.len(), 2, "p1 + p2 mount kb_a");
using_a.sort_by(|x, y| x.target_workpath.cmp(&y.target_workpath));
assert_eq!(using_a[0].target_workpath.as_deref(), Some("/p1"));
assert!(using_a[0].enabled);
assert_eq!(using_a[1].target_workpath.as_deref(), Some("/p2"));
assert!(!using_a[1].enabled, "disabled binding still listed");
assert_eq!(repo.list_bindings_using_kb("kb_b").await.unwrap().len(), 2, "p2 + p3 mount kb_b");
assert!(repo.list_bindings_using_kb("kb_missing").await.unwrap().is_empty());
}
#[tokio::test]
async fn knowledge_tags_crud_roundtrip() {
use crate::models::{CreateKnowledgeTagParams, UpdateKnowledgeTagParams};
let db = init_database_memory().await.unwrap();
let repo = SqliteKnowledgeRepository::new(db.pool().clone());
// Initially empty.
assert!(repo.list_knowledge_tags().await.unwrap().is_empty());
// Create.
repo.create_knowledge_tag(CreateKnowledgeTagParams {
key: "research".into(),
label: "研发".into(),
color: Some("#4d9fff".into()),
sort_order: 0,
created_at: 1,
})
.await
.unwrap();
let tags = repo.list_knowledge_tags().await.unwrap();
assert_eq!(tags.len(), 1);
assert_eq!(tags[0].key, "research");
assert_eq!(tags[0].label, "研发");
assert_eq!(tags[0].color.as_deref(), Some("#4d9fff"));
assert_eq!(tags[0].sort_order, 0);
assert_eq!(tags[0].created_at, 1);
// Update label only.
repo.update_knowledge_tag("research", UpdateKnowledgeTagParams {
label: Some("研发线".into()),
..Default::default()
})
.await
.unwrap();
let tags = repo.list_knowledge_tags().await.unwrap();
assert_eq!(tags[0].label, "研发线");
assert_eq!(tags[0].color.as_deref(), Some("#4d9fff"), "untouched field preserved");
// Update color to None.
repo.update_knowledge_tag("research", UpdateKnowledgeTagParams {
color: Some(None),
..Default::default()
})
.await
.unwrap();
let tags = repo.list_knowledge_tags().await.unwrap();
assert!(tags[0].color.is_none(), "color cleared");
// Delete.
repo.delete_knowledge_tag("research").await.unwrap();
assert!(repo.list_knowledge_tags().await.unwrap().is_empty());
// Delete absent → NotFound.
assert!(matches!(
repo.delete_knowledge_tag("research").await,
Err(DbError::NotFound(_))
));
// Update absent → NotFound.
assert!(matches!(
repo.update_knowledge_tag("missing", UpdateKnowledgeTagParams::default()).await,
Err(DbError::NotFound(_))
));
}
/// The `tags` column on `knowledge_bases` is read/written through the
/// existing base CRUD methods.
#[tokio::test]
async fn base_tags_column_roundtrip() {
let db = init_database_memory().await.unwrap();
let repo = SqliteKnowledgeRepository::new(db.pool().clone());
// Insert with tags = None (default for old rows).
repo.insert_base(&make_base("kb_t")).await.unwrap();
let row = repo.get_base("kb_t").await.unwrap().unwrap();
assert!(row.tags.is_none(), "NULL maps to None");
// Update with a JSON tags value.
let mut row = row;
row.tags = Some(r#"["research","ops"]"#.into());
row.updated_at = 2;
repo.update_base(&row).await.unwrap();
let got = repo.get_base("kb_t").await.unwrap().unwrap();
assert_eq!(got.tags.as_deref(), Some(r#"["research","ops"]"#));
// list_bases also returns the tags column.
let all = repo.list_bases().await.unwrap();
assert_eq!(all[0].tags.as_deref(), Some(r#"["research","ops"]"#));
}
}
@@ -0,0 +1,598 @@
use std::collections::HashMap;
use nomifun_common::TimestampMs;
use sqlx::QueryBuilder;
use sqlx::SqlitePool;
use crate::error::DbError;
use crate::models::McpServerRow;
use crate::repository::mcp_server::{CreateMcpServerParams, IMcpServerRepository, UpdateMcpServerParams};
/// SQLite-backed implementation of [`IMcpServerRepository`].
#[derive(Clone, Debug)]
pub struct SqliteMcpServerRepository {
pool: SqlitePool,
}
impl SqliteMcpServerRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait::async_trait]
impl IMcpServerRepository for SqliteMcpServerRepository {
async fn list(&self) -> Result<Vec<McpServerRow>, DbError> {
let rows = sqlx::query_as::<_, McpServerRow>(
"SELECT * FROM mcp_servers WHERE deleted_at IS NULL ORDER BY created_at ASC",
)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
async fn find_by_id(&self, id: i64) -> Result<Option<McpServerRow>, DbError> {
let row = sqlx::query_as::<_, McpServerRow>("SELECT * FROM mcp_servers WHERE id = ? AND deleted_at IS NULL")
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
async fn find_by_name(&self, name: &str) -> Result<Option<McpServerRow>, DbError> {
let row = sqlx::query_as::<_, McpServerRow>("SELECT * FROM mcp_servers WHERE name = ? AND deleted_at IS NULL")
.bind(name)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
async fn find_by_id_any(&self, id: i64) -> Result<Option<McpServerRow>, DbError> {
let row = sqlx::query_as::<_, McpServerRow>("SELECT * FROM mcp_servers WHERE id = ?")
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
async fn find_by_name_any(&self, name: &str) -> Result<Option<McpServerRow>, DbError> {
let row = sqlx::query_as::<_, McpServerRow>("SELECT * FROM mcp_servers WHERE name = ?")
.bind(name)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
async fn list_by_ids_any(&self, ids: &[i64]) -> Result<Vec<McpServerRow>, DbError> {
if ids.is_empty() {
return Ok(Vec::new());
}
let mut query = QueryBuilder::new("SELECT * FROM mcp_servers WHERE id IN (");
let mut separated = query.separated(", ");
for id in ids {
separated.push_bind(*id);
}
separated.push_unseparated(") ORDER BY created_at ASC");
let rows = query.build_query_as::<McpServerRow>().fetch_all(&self.pool).await?;
let rows_by_id: HashMap<_, _> = rows.into_iter().map(|row| (row.id, row)).collect();
Ok(ids.iter().filter_map(|id| rows_by_id.get(id).cloned()).collect())
}
async fn create(&self, params: CreateMcpServerParams<'_>) -> Result<McpServerRow, DbError> {
let now = nomifun_common::now_ms();
let last_test_status = "disconnected";
let result = sqlx::query(
"INSERT INTO mcp_servers \
(name, description, enabled, transport_type, transport_config, \
tools, last_test_status, last_connected, original_json, builtin, \
deleted_at, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(params.name)
.bind(params.description)
.bind(params.enabled)
.bind(params.transport_type)
.bind(params.transport_config)
.bind(params.tools)
.bind(last_test_status)
.bind(Option::<TimestampMs>::None)
.bind(params.original_json)
.bind(params.builtin)
.bind(Option::<TimestampMs>::None)
.bind(now)
.bind(now)
.execute(&self.pool)
.await
.map_err(|e| match &e {
sqlx::Error::Database(db_err) if is_unique_violation(db_err.as_ref()) => {
DbError::Conflict(format!("MCP server name '{}' already exists", params.name))
}
_ => DbError::Query(e),
})?;
let id = result.last_insert_rowid();
Ok(McpServerRow {
id,
name: params.name.to_string(),
description: params.description.map(String::from),
enabled: params.enabled,
transport_type: params.transport_type.to_string(),
transport_config: params.transport_config.to_string(),
tools: params.tools.map(String::from),
last_test_status: last_test_status.to_string(),
last_connected: None,
original_json: params.original_json.map(String::from),
builtin: params.builtin,
deleted_at: None,
created_at: now,
updated_at: now,
})
}
async fn update(&self, id: i64, params: UpdateMcpServerParams<'_>) -> Result<McpServerRow, DbError> {
let existing = self
.find_by_id_any(id)
.await?
.ok_or_else(|| DbError::NotFound(format!("MCP server '{id}' not found")))?;
let merged = merge_update(existing, params);
sqlx::query(
"UPDATE mcp_servers SET \
name = ?, description = ?, enabled = ?, transport_type = ?, \
transport_config = ?, tools = ?, original_json = ?, \
builtin = ?, deleted_at = ?, updated_at = ? \
WHERE id = ?",
)
.bind(&merged.name)
.bind(&merged.description)
.bind(merged.enabled)
.bind(&merged.transport_type)
.bind(&merged.transport_config)
.bind(&merged.tools)
.bind(&merged.original_json)
.bind(merged.builtin)
.bind(merged.deleted_at)
.bind(merged.updated_at)
.bind(id)
.execute(&self.pool)
.await
.map_err(|e| match &e {
sqlx::Error::Database(db_err) if is_unique_violation(db_err.as_ref()) => {
DbError::Conflict(format!("MCP server name '{}' already exists", merged.name))
}
_ => DbError::Query(e),
})?;
Ok(merged)
}
async fn delete(&self, id: i64) -> Result<(), DbError> {
let now = nomifun_common::now_ms();
let result = sqlx::query(
"UPDATE mcp_servers SET enabled = 0, deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL",
)
.bind(now)
.bind(now)
.bind(id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(DbError::NotFound(format!("MCP server '{id}' not found")));
}
Ok(())
}
async fn batch_upsert(&self, servers: &[CreateMcpServerParams<'_>]) -> Result<Vec<McpServerRow>, DbError> {
let mut results = Vec::with_capacity(servers.len());
for params in servers {
let row = match self.find_by_name(params.name).await? {
Some(existing) => {
let update_params = UpdateMcpServerParams {
description: Some(params.description),
enabled: Some(params.enabled),
transport_type: Some(params.transport_type),
transport_config: Some(params.transport_config),
tools: Some(params.tools),
original_json: Some(params.original_json),
builtin: Some(params.builtin),
..Default::default()
};
self.update(existing.id, update_params).await?
}
None => self.create(params.clone()).await?,
};
results.push(row);
}
Ok(results)
}
async fn update_status(&self, id: i64, status: &str, last_connected: Option<TimestampMs>) -> Result<(), DbError> {
let now = nomifun_common::now_ms();
let result = sqlx::query(
"UPDATE mcp_servers SET last_test_status = ?, \
last_connected = COALESCE(?, last_connected), \
updated_at = ? WHERE id = ? AND deleted_at IS NULL",
)
.bind(status)
.bind(last_connected)
.bind(now)
.bind(id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(DbError::NotFound(format!("MCP server '{id}' not found")));
}
Ok(())
}
async fn update_tools(&self, id: i64, tools: Option<&str>) -> Result<(), DbError> {
let now = nomifun_common::now_ms();
let result =
sqlx::query("UPDATE mcp_servers SET tools = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL")
.bind(tools)
.bind(now)
.bind(id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(DbError::NotFound(format!("MCP server '{id}' not found")));
}
Ok(())
}
}
/// Merge partial update params into an existing row, returning a new instance.
fn merge_update(existing: McpServerRow, params: UpdateMcpServerParams<'_>) -> McpServerRow {
let now = nomifun_common::now_ms();
McpServerRow {
id: existing.id,
name: params.name.unwrap_or(&existing.name).to_string(),
description: params.description.map_or(existing.description, |v| v.map(String::from)),
enabled: params.enabled.unwrap_or(existing.enabled),
transport_type: params.transport_type.unwrap_or(&existing.transport_type).to_string(),
transport_config: params
.transport_config
.unwrap_or(&existing.transport_config)
.to_string(),
tools: params.tools.map_or(existing.tools, |v| v.map(String::from)),
last_test_status: existing.last_test_status,
last_connected: existing.last_connected,
original_json: params
.original_json
.map_or(existing.original_json, |v| v.map(String::from)),
builtin: params.builtin.unwrap_or(existing.builtin),
deleted_at: params.deleted_at.map_or(existing.deleted_at, |v| v),
created_at: existing.created_at,
updated_at: now,
}
}
fn is_unique_violation(err: &dyn sqlx::error::DatabaseError) -> bool {
err.code().is_some_and(|c| c == "2067")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::init_database_memory;
async fn setup() -> (SqliteMcpServerRepository, crate::Database) {
let db = init_database_memory().await.unwrap();
let repo = SqliteMcpServerRepository::new(db.pool().clone());
(repo, db)
}
fn stdio_params() -> CreateMcpServerParams<'static> {
CreateMcpServerParams {
name: "test-mcp",
description: Some("A test MCP server"),
enabled: false,
transport_type: "stdio",
transport_config: r#"{"command":"npx","args":["-y","test-server"]}"#,
tools: None,
original_json: Some(r#"{"name":"test-mcp"}"#),
builtin: false,
}
}
fn http_params() -> CreateMcpServerParams<'static> {
CreateMcpServerParams {
name: "http-mcp",
description: None,
enabled: true,
transport_type: "http",
transport_config: r#"{"url":"https://example.com/mcp"}"#,
tools: None,
original_json: None,
builtin: false,
}
}
#[tokio::test]
async fn list_empty() {
let (repo, _db) = setup().await;
let servers = repo.list().await.unwrap();
assert!(servers.is_empty());
}
#[tokio::test]
async fn create_returns_populated_fields() {
let (repo, _db) = setup().await;
let server = repo.create(stdio_params()).await.unwrap();
assert!(server.id > 0);
assert_eq!(server.name, "test-mcp");
assert_eq!(server.description.as_deref(), Some("A test MCP server"));
assert!(!server.enabled);
assert_eq!(server.transport_type, "stdio");
assert!(server.transport_config.contains("npx"));
assert!(server.tools.is_none());
assert_eq!(server.last_test_status, "disconnected");
assert!(server.last_connected.is_none());
assert!(server.original_json.is_some());
assert!(!server.builtin);
assert!(server.created_at > 0);
assert_eq!(server.created_at, server.updated_at);
}
#[tokio::test]
async fn create_duplicate_name_returns_conflict() {
let (repo, _db) = setup().await;
repo.create(stdio_params()).await.unwrap();
let err = repo.create(stdio_params()).await.unwrap_err();
assert!(matches!(err, DbError::Conflict(_)));
}
#[tokio::test]
async fn find_by_id_returns_record() {
let (repo, _db) = setup().await;
let created = repo.create(stdio_params()).await.unwrap();
let found = repo.find_by_id(created.id).await.unwrap().unwrap();
assert_eq!(found.id, created.id);
assert_eq!(found.name, "test-mcp");
}
#[tokio::test]
async fn find_by_id_nonexistent() {
let (repo, _db) = setup().await;
assert!(repo.find_by_id(999_999).await.unwrap().is_none());
}
#[tokio::test]
async fn find_by_name_returns_record() {
let (repo, _db) = setup().await;
let created = repo.create(stdio_params()).await.unwrap();
let found = repo.find_by_name("test-mcp").await.unwrap().unwrap();
assert_eq!(found.id, created.id);
}
#[tokio::test]
async fn find_by_name_nonexistent() {
let (repo, _db) = setup().await;
assert!(repo.find_by_name("nope").await.unwrap().is_none());
}
#[tokio::test]
async fn list_returns_all_ordered() {
let (repo, _db) = setup().await;
let s1 = repo.create(stdio_params()).await.unwrap();
let s2 = repo.create(http_params()).await.unwrap();
let all = repo.list().await.unwrap();
assert_eq!(all.len(), 2);
assert_eq!(all[0].id, s1.id);
assert_eq!(all[1].id, s2.id);
}
#[tokio::test]
async fn update_partial_fields() {
let (repo, _db) = setup().await;
let created = repo.create(stdio_params()).await.unwrap();
let updated = repo
.update(
created.id,
UpdateMcpServerParams {
enabled: Some(true),
..Default::default()
},
)
.await
.unwrap();
assert!(updated.enabled);
assert_eq!(updated.name, "test-mcp");
assert_eq!(updated.transport_type, "stdio");
assert!(updated.updated_at >= created.updated_at);
}
#[tokio::test]
async fn update_name_conflict_returns_conflict() {
let (repo, _db) = setup().await;
repo.create(stdio_params()).await.unwrap();
let s2 = repo.create(http_params()).await.unwrap();
let err = repo
.update(
s2.id,
UpdateMcpServerParams {
name: Some("test-mcp"),
..Default::default()
},
)
.await
.unwrap_err();
assert!(matches!(err, DbError::Conflict(_)));
}
#[tokio::test]
async fn update_clear_optional_fields() {
let (repo, _db) = setup().await;
let created = repo.create(stdio_params()).await.unwrap();
assert!(created.description.is_some());
let updated = repo
.update(
created.id,
UpdateMcpServerParams {
description: Some(None),
original_json: Some(None),
..Default::default()
},
)
.await
.unwrap();
assert!(updated.description.is_none());
assert!(updated.original_json.is_none());
}
#[tokio::test]
async fn update_nonexistent_returns_not_found() {
let (repo, _db) = setup().await;
let err = repo
.update(999_999, UpdateMcpServerParams::default())
.await
.unwrap_err();
assert!(matches!(err, DbError::NotFound(_)));
}
#[tokio::test]
async fn delete_existing() {
let (repo, _db) = setup().await;
let created = repo.create(stdio_params()).await.unwrap();
repo.delete(created.id).await.unwrap();
assert!(repo.find_by_id(created.id).await.unwrap().is_none());
}
#[tokio::test]
async fn delete_nonexistent_returns_not_found() {
let (repo, _db) = setup().await;
let err = repo.delete(999_999).await.unwrap_err();
assert!(matches!(err, DbError::NotFound(_)));
}
#[tokio::test]
async fn batch_upsert_creates_new_and_updates_existing() {
let (repo, _db) = setup().await;
let existing = repo.create(stdio_params()).await.unwrap();
assert!(!existing.enabled);
let results = repo
.batch_upsert(&[
CreateMcpServerParams {
enabled: true,
..stdio_params()
},
http_params(),
])
.await
.unwrap();
assert_eq!(results.len(), 2);
// Existing was updated (same ID, enabled changed)
assert_eq!(results[0].id, existing.id);
assert!(results[0].enabled);
// New was created
assert_eq!(results[1].name, "http-mcp");
assert!(results[1].id > 0);
}
#[tokio::test]
async fn update_status_sets_status_and_last_connected() {
let (repo, _db) = setup().await;
let created = repo.create(stdio_params()).await.unwrap();
let ts = nomifun_common::now_ms();
repo.update_status(created.id, "connected", Some(ts)).await.unwrap();
let found = repo.find_by_id(created.id).await.unwrap().unwrap();
assert_eq!(found.last_test_status, "connected");
assert_eq!(found.last_connected, Some(ts));
}
#[tokio::test]
async fn update_status_without_timestamp_preserves_existing() {
let (repo, _db) = setup().await;
let created = repo.create(stdio_params()).await.unwrap();
let ts = nomifun_common::now_ms();
repo.update_status(created.id, "connected", Some(ts)).await.unwrap();
repo.update_status(created.id, "error", None).await.unwrap();
let found = repo.find_by_id(created.id).await.unwrap().unwrap();
assert_eq!(found.last_test_status, "error");
assert_eq!(found.last_connected, Some(ts));
}
#[tokio::test]
async fn update_status_nonexistent_returns_not_found() {
let (repo, _db) = setup().await;
let err = repo.update_status(999_999, "connected", None).await.unwrap_err();
assert!(matches!(err, DbError::NotFound(_)));
}
#[tokio::test]
async fn update_tools_sets_tools_json() {
let (repo, _db) = setup().await;
let created = repo.create(stdio_params()).await.unwrap();
assert!(created.tools.is_none());
let tools_json = r#"[{"name":"read_file","description":"Read a file"}]"#;
repo.update_tools(created.id, Some(tools_json)).await.unwrap();
let found = repo.find_by_id(created.id).await.unwrap().unwrap();
assert_eq!(found.tools.as_deref(), Some(tools_json));
}
#[tokio::test]
async fn update_tools_clear() {
let (repo, _db) = setup().await;
let created = repo
.create(CreateMcpServerParams {
tools: Some(r#"[{"name":"tool"}]"#),
..stdio_params()
})
.await
.unwrap();
assert!(created.tools.is_some());
repo.update_tools(created.id, None).await.unwrap();
let found = repo.find_by_id(created.id).await.unwrap().unwrap();
assert!(found.tools.is_none());
}
#[tokio::test]
async fn update_tools_nonexistent_returns_not_found() {
let (repo, _db) = setup().await;
let err = repo.update_tools(999_999, Some("[]")).await.unwrap_err();
assert!(matches!(err, DbError::NotFound(_)));
}
}
@@ -0,0 +1,202 @@
use sqlx::SqlitePool;
use crate::error::DbError;
use crate::models::OAuthTokenRow;
use crate::repository::oauth_token::{IOAuthTokenRepository, UpsertOAuthTokenParams};
/// SQLite-backed implementation of [`IOAuthTokenRepository`].
#[derive(Clone, Debug)]
pub struct SqliteOAuthTokenRepository {
pool: SqlitePool,
}
impl SqliteOAuthTokenRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait::async_trait]
impl IOAuthTokenRepository for SqliteOAuthTokenRepository {
async fn get_by_url(&self, server_url: &str) -> Result<Option<OAuthTokenRow>, DbError> {
let row = sqlx::query_as::<_, OAuthTokenRow>("SELECT * FROM oauth_tokens WHERE server_url = ?")
.bind(server_url)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
async fn upsert(&self, params: UpsertOAuthTokenParams<'_>) -> Result<OAuthTokenRow, DbError> {
let now = nomifun_common::now_ms();
sqlx::query(
"INSERT INTO oauth_tokens \
(server_url, access_token, refresh_token, token_type, \
expires_at, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?) \
ON CONFLICT(server_url) DO UPDATE SET \
access_token = excluded.access_token, \
refresh_token = excluded.refresh_token, \
token_type = excluded.token_type, \
expires_at = excluded.expires_at, \
updated_at = excluded.updated_at",
)
.bind(params.server_url)
.bind(params.access_token)
.bind(params.refresh_token)
.bind(params.token_type)
.bind(params.expires_at)
.bind(now)
.bind(now)
.execute(&self.pool)
.await?;
// Fetch the row to get the correct created_at (preserved on conflict).
let row = self
.get_by_url(params.server_url)
.await?
.ok_or_else(|| DbError::Init("Upsert succeeded but row not found".to_string()))?;
Ok(row)
}
async fn delete(&self, server_url: &str) -> Result<(), DbError> {
let result = sqlx::query("DELETE FROM oauth_tokens WHERE server_url = ?")
.bind(server_url)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(DbError::NotFound(format!("OAuth token for '{server_url}' not found")));
}
Ok(())
}
async fn list_authenticated_urls(&self) -> Result<Vec<String>, DbError> {
let rows: Vec<(String,)> = sqlx::query_as("SELECT server_url FROM oauth_tokens ORDER BY created_at ASC")
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(|(url,)| url).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::init_database_memory;
async fn setup() -> (SqliteOAuthTokenRepository, crate::Database) {
let db = init_database_memory().await.unwrap();
let repo = SqliteOAuthTokenRepository::new(db.pool().clone());
(repo, db)
}
fn sample_params() -> UpsertOAuthTokenParams<'static> {
UpsertOAuthTokenParams {
server_url: "https://mcp.example.com",
access_token: "enc_access_token_123",
refresh_token: Some("enc_refresh_token_456"),
token_type: "bearer",
expires_at: Some(1700000000000),
}
}
#[tokio::test]
async fn get_by_url_nonexistent() {
let (repo, _db) = setup().await;
assert!(repo.get_by_url("https://nope.com").await.unwrap().is_none());
}
#[tokio::test]
async fn upsert_insert_new_token() {
let (repo, _db) = setup().await;
let token = repo.upsert(sample_params()).await.unwrap();
assert_eq!(token.server_url, "https://mcp.example.com");
assert_eq!(token.access_token, "enc_access_token_123");
assert_eq!(token.refresh_token.as_deref(), Some("enc_refresh_token_456"));
assert_eq!(token.token_type, "bearer");
assert_eq!(token.expires_at, Some(1700000000000));
assert!(token.created_at > 0);
assert_eq!(token.created_at, token.updated_at);
}
#[tokio::test]
async fn upsert_updates_existing_token() {
let (repo, _db) = setup().await;
let original = repo.upsert(sample_params()).await.unwrap();
let updated = repo
.upsert(UpsertOAuthTokenParams {
server_url: "https://mcp.example.com",
access_token: "new_access_token",
refresh_token: None,
token_type: "bearer",
expires_at: Some(1800000000000),
})
.await
.unwrap();
assert_eq!(updated.server_url, original.server_url);
assert_eq!(updated.access_token, "new_access_token");
assert!(updated.refresh_token.is_none());
assert_eq!(updated.expires_at, Some(1800000000000));
// created_at preserved from original insert
assert_eq!(updated.created_at, original.created_at);
}
#[tokio::test]
async fn get_by_url_returns_upserted_token() {
let (repo, _db) = setup().await;
repo.upsert(sample_params()).await.unwrap();
let found = repo.get_by_url("https://mcp.example.com").await.unwrap().unwrap();
assert_eq!(found.access_token, "enc_access_token_123");
}
#[tokio::test]
async fn delete_existing_token() {
let (repo, _db) = setup().await;
repo.upsert(sample_params()).await.unwrap();
repo.delete("https://mcp.example.com").await.unwrap();
assert!(repo.get_by_url("https://mcp.example.com").await.unwrap().is_none());
}
#[tokio::test]
async fn delete_nonexistent_returns_not_found() {
let (repo, _db) = setup().await;
let err = repo.delete("https://nope.com").await.unwrap_err();
assert!(matches!(err, DbError::NotFound(_)));
}
#[tokio::test]
async fn list_authenticated_urls_empty() {
let (repo, _db) = setup().await;
let urls = repo.list_authenticated_urls().await.unwrap();
assert!(urls.is_empty());
}
#[tokio::test]
async fn list_authenticated_urls_returns_all() {
let (repo, _db) = setup().await;
repo.upsert(sample_params()).await.unwrap();
repo.upsert(UpsertOAuthTokenParams {
server_url: "https://other.example.com",
access_token: "token2",
refresh_token: None,
token_type: "bearer",
expires_at: None,
})
.await
.unwrap();
let urls = repo.list_authenticated_urls().await.unwrap();
assert_eq!(urls.len(), 2);
assert!(urls.contains(&"https://mcp.example.com".to_string()));
assert!(urls.contains(&"https://other.example.com".to_string()));
}
}
@@ -0,0 +1,442 @@
use sqlx::SqlitePool;
use crate::error::DbError;
use crate::models::Provider;
use crate::repository::IProviderRepository;
use crate::repository::provider::{CreateProviderParams, UpdateProviderParams};
/// SQLite-backed implementation of [`IProviderRepository`].
#[derive(Clone, Debug)]
pub struct SqliteProviderRepository {
pool: SqlitePool,
}
impl SqliteProviderRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait::async_trait]
impl IProviderRepository for SqliteProviderRepository {
async fn list(&self) -> Result<Vec<Provider>, DbError> {
let rows = sqlx::query_as::<_, Provider>("SELECT * FROM providers ORDER BY created_at ASC")
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
async fn find_by_id(&self, id: &str) -> Result<Option<Provider>, DbError> {
let row = sqlx::query_as::<_, Provider>("SELECT * FROM providers WHERE id = ?")
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
async fn create(&self, params: CreateProviderParams<'_>) -> Result<Provider, DbError> {
let id = params
.id
.map(String::from)
.unwrap_or_else(|| nomifun_common::generate_prefixed_id("prov"));
let now = nomifun_common::now_ms();
sqlx::query(
"INSERT INTO providers \
(id, platform, name, base_url, api_key_encrypted, models, enabled, \
capabilities, context_limit, model_protocols, model_enabled, \
model_health, bedrock_config, is_full_url, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(params.platform)
.bind(params.name)
.bind(params.base_url)
.bind(params.api_key_encrypted)
.bind(params.models)
.bind(params.enabled)
.bind(params.capabilities)
.bind(params.context_limit)
.bind(params.model_protocols)
.bind(params.model_enabled)
.bind(params.model_health)
.bind(params.bedrock_config)
.bind(params.is_full_url)
.bind(now)
.bind(now)
.execute(&self.pool)
.await
.map_err(|e| match &e {
sqlx::Error::Database(db_err) if is_unique_violation(db_err.as_ref()) => {
DbError::Conflict(format!("Provider with id '{id}' already exists"))
}
_ => DbError::Query(e),
})?;
Ok(Provider {
id,
platform: params.platform.to_string(),
name: params.name.to_string(),
base_url: params.base_url.to_string(),
api_key_encrypted: params.api_key_encrypted.to_string(),
models: params.models.to_string(),
enabled: params.enabled,
capabilities: params.capabilities.to_string(),
context_limit: params.context_limit,
model_protocols: params.model_protocols.map(String::from),
model_enabled: params.model_enabled.map(String::from),
model_health: params.model_health.map(String::from),
bedrock_config: params.bedrock_config.map(String::from),
is_full_url: params.is_full_url,
created_at: now,
updated_at: now,
})
}
async fn update(&self, id: &str, params: UpdateProviderParams<'_>) -> Result<Provider, DbError> {
let existing = self
.find_by_id(id)
.await?
.ok_or_else(|| DbError::NotFound(format!("Provider '{id}' not found")))?;
let merged = merge_update(existing, params);
sqlx::query(
"UPDATE providers SET \
platform = ?, name = ?, base_url = ?, api_key_encrypted = ?, \
models = ?, enabled = ?, capabilities = ?, context_limit = ?, \
model_protocols = ?, model_enabled = ?, model_health = ?, \
bedrock_config = ?, is_full_url = ?, updated_at = ? \
WHERE id = ?",
)
.bind(&merged.platform)
.bind(&merged.name)
.bind(&merged.base_url)
.bind(&merged.api_key_encrypted)
.bind(&merged.models)
.bind(merged.enabled)
.bind(&merged.capabilities)
.bind(merged.context_limit)
.bind(&merged.model_protocols)
.bind(&merged.model_enabled)
.bind(&merged.model_health)
.bind(&merged.bedrock_config)
.bind(merged.is_full_url)
.bind(merged.updated_at)
.bind(id)
.execute(&self.pool)
.await?;
Ok(merged)
}
async fn delete(&self, id: &str) -> Result<(), DbError> {
let result = sqlx::query("DELETE FROM providers WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(DbError::NotFound(format!("Provider '{id}' not found")));
}
Ok(())
}
}
/// Detect SQLite UNIQUE constraint violation (codes 2067 / 1555).
fn is_unique_violation(err: &dyn sqlx::error::DatabaseError) -> bool {
err.code().is_some_and(|c| c == "2067" || c == "1555")
}
/// Merge partial update params into an existing provider, returning a new instance.
fn merge_update(existing: Provider, params: UpdateProviderParams<'_>) -> Provider {
let now = nomifun_common::now_ms();
Provider {
id: existing.id,
platform: params.platform.unwrap_or(&existing.platform).to_string(),
name: params.name.unwrap_or(&existing.name).to_string(),
base_url: params.base_url.unwrap_or(&existing.base_url).to_string(),
api_key_encrypted: params
.api_key_encrypted
.unwrap_or(&existing.api_key_encrypted)
.to_string(),
models: params.models.unwrap_or(&existing.models).to_string(),
enabled: params.enabled.unwrap_or(existing.enabled),
capabilities: params.capabilities.unwrap_or(&existing.capabilities).to_string(),
context_limit: params.context_limit.unwrap_or(existing.context_limit),
model_protocols: params
.model_protocols
.map_or(existing.model_protocols, |v| v.map(String::from)),
model_enabled: params
.model_enabled
.map_or(existing.model_enabled, |v| v.map(String::from)),
model_health: params
.model_health
.map_or(existing.model_health, |v| v.map(String::from)),
bedrock_config: params
.bedrock_config
.map_or(existing.bedrock_config, |v| v.map(String::from)),
is_full_url: params.is_full_url.unwrap_or(existing.is_full_url),
created_at: existing.created_at,
updated_at: now,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::init_database_memory;
async fn setup() -> (SqliteProviderRepository, crate::Database) {
let db = init_database_memory().await.unwrap();
let repo = SqliteProviderRepository::new(db.pool().clone());
(repo, db)
}
fn sample_params() -> CreateProviderParams<'static> {
CreateProviderParams {
id: None,
platform: "anthropic",
name: "Anthropic",
base_url: "https://api.anthropic.com",
api_key_encrypted: "encrypted_key_data",
models: r#"["claude-sonnet-4-20250514"]"#,
enabled: true,
capabilities: r#"[{"type":"text"}]"#,
context_limit: Some(200000),
model_protocols: None,
model_enabled: None,
model_health: None,
bedrock_config: None,
is_full_url: false,
}
}
#[tokio::test]
async fn list_empty() {
let (repo, _db) = setup().await;
let providers = repo.list().await.unwrap();
assert!(providers.is_empty());
}
#[tokio::test]
async fn create_returns_populated_fields() {
let (repo, _db) = setup().await;
let p = repo.create(sample_params()).await.unwrap();
assert!(p.id.starts_with("prov_"));
assert_eq!(p.platform, "anthropic");
assert_eq!(p.name, "Anthropic");
assert_eq!(p.base_url, "https://api.anthropic.com");
assert_eq!(p.api_key_encrypted, "encrypted_key_data");
assert!(p.enabled);
assert_eq!(p.context_limit, Some(200000));
assert!(p.model_protocols.is_none());
assert!(p.bedrock_config.is_none());
assert!(p.created_at > 0);
assert_eq!(p.created_at, p.updated_at);
}
#[tokio::test]
async fn create_with_caller_supplied_id() {
let (repo, _db) = setup().await;
let p = repo
.create(CreateProviderParams {
id: Some("my-custom-id-1"),
..sample_params()
})
.await
.unwrap();
assert_eq!(p.id, "my-custom-id-1");
assert_eq!(p.platform, "anthropic");
let found = repo.find_by_id("my-custom-id-1").await.unwrap().unwrap();
assert_eq!(found.id, "my-custom-id-1");
}
#[tokio::test]
async fn create_with_duplicate_id_returns_conflict() {
let (repo, _db) = setup().await;
repo.create(CreateProviderParams {
id: Some("dup-id"),
..sample_params()
})
.await
.unwrap();
let err = repo
.create(CreateProviderParams {
id: Some("dup-id"),
..sample_params()
})
.await
.unwrap_err();
assert!(matches!(err, DbError::Conflict(_)));
}
#[tokio::test]
async fn create_then_find_by_id() {
let (repo, _db) = setup().await;
let created = repo.create(sample_params()).await.unwrap();
let found = repo.find_by_id(&created.id).await.unwrap().unwrap();
assert_eq!(found.id, created.id);
assert_eq!(found.platform, "anthropic");
assert_eq!(found.models, r#"["claude-sonnet-4-20250514"]"#);
}
#[tokio::test]
async fn find_by_id_nonexistent() {
let (repo, _db) = setup().await;
assert!(repo.find_by_id("no_such_id").await.unwrap().is_none());
}
#[tokio::test]
async fn list_returns_all_ordered_by_created_at() {
let (repo, _db) = setup().await;
let p1 = repo.create(sample_params()).await.unwrap();
let p2 = repo
.create(CreateProviderParams {
platform: "openai",
name: "OpenAI",
base_url: "https://api.openai.com",
..sample_params()
})
.await
.unwrap();
let all = repo.list().await.unwrap();
assert_eq!(all.len(), 2);
assert_eq!(all[0].id, p1.id);
assert_eq!(all[1].id, p2.id);
}
#[tokio::test]
async fn update_partial_fields() {
let (repo, _db) = setup().await;
let created = repo.create(sample_params()).await.unwrap();
let updated = repo
.update(
&created.id,
UpdateProviderParams {
name: Some("Anthropic Updated"),
enabled: Some(false),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.name, "Anthropic Updated");
assert!(!updated.enabled);
// Unchanged fields preserved
assert_eq!(updated.platform, "anthropic");
assert_eq!(updated.base_url, "https://api.anthropic.com");
assert!(updated.updated_at >= created.updated_at);
}
#[tokio::test]
async fn update_api_key() {
let (repo, _db) = setup().await;
let created = repo.create(sample_params()).await.unwrap();
let updated = repo
.update(
&created.id,
UpdateProviderParams {
api_key_encrypted: Some("new_encrypted_key"),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.api_key_encrypted, "new_encrypted_key");
}
#[tokio::test]
async fn update_nonexistent_returns_not_found() {
let (repo, _db) = setup().await;
let err = repo.update("no_id", UpdateProviderParams::default()).await.unwrap_err();
assert!(matches!(err, DbError::NotFound(_)));
}
#[tokio::test]
async fn update_optional_json_fields() {
let (repo, _db) = setup().await;
let created = repo.create(sample_params()).await.unwrap();
assert!(created.model_protocols.is_none());
// Set optional field
let updated = repo
.update(
&created.id,
UpdateProviderParams {
model_protocols: Some(Some(r#"{"model1":"openai"}"#)),
bedrock_config: Some(Some(r#"{"region":"us-east-1"}"#)),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.model_protocols.as_deref(), Some(r#"{"model1":"openai"}"#));
assert_eq!(updated.bedrock_config.as_deref(), Some(r#"{"region":"us-east-1"}"#));
// Clear optional field
let cleared = repo
.update(
&created.id,
UpdateProviderParams {
model_protocols: Some(None),
..Default::default()
},
)
.await
.unwrap();
assert!(cleared.model_protocols.is_none());
// bedrock_config should still be set
assert!(cleared.bedrock_config.is_some());
}
#[tokio::test]
async fn delete_existing() {
let (repo, _db) = setup().await;
let created = repo.create(sample_params()).await.unwrap();
repo.delete(&created.id).await.unwrap();
assert!(repo.find_by_id(&created.id).await.unwrap().is_none());
}
#[tokio::test]
async fn delete_nonexistent_returns_not_found() {
let (repo, _db) = setup().await;
let err = repo.delete("no_id").await.unwrap_err();
assert!(matches!(err, DbError::NotFound(_)));
}
#[tokio::test]
async fn delete_then_list_excludes_deleted() {
let (repo, _db) = setup().await;
let p1 = repo.create(sample_params()).await.unwrap();
let p2 = repo
.create(CreateProviderParams {
name: "Other",
..sample_params()
})
.await
.unwrap();
repo.delete(&p1.id).await.unwrap();
let all = repo.list().await.unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].id, p2.id);
}
}

Some files were not shown because too many files have changed in this diff Show More