Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -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);
|
||||
+20
@@ -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_bindings:target_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.extra:petCompanion -> 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
|
||||
------------------------------------------------------------------------
|
||||
+29
@@ -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
|
||||
/// 001–021 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 001–021 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 2–21 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(¶ms("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(¶ms("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(¶ms("u1", "A")).await.unwrap();
|
||||
let err = a.create(¶ms("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(¶ms("u1", "first")).await.unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
|
||||
a.create(¶ms("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(¶ms("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(¶ms("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(¶ms("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(¶ms("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(¶ms.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(¶ms.action)
|
||||
.bind(params.category.as_str())
|
||||
.bind(¶ms.user_id)
|
||||
.bind(¶ms.username)
|
||||
.bind(¶ms.ip_address)
|
||||
.bind(¶ms.user_agent)
|
||||
.bind(¶ms.resource_type)
|
||||
.bind(¶ms.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(¶ms);
|
||||
|
||||
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(¶ms.primary_color.as_ref().unwrap())
|
||||
.bind(¶ms.secondary_color.as_ref().unwrap())
|
||||
.bind(¶ms.accent_color.as_ref().unwrap())
|
||||
.bind(¶ms.background_light.as_ref().unwrap())
|
||||
.bind(¶ms.background_dark.as_ref().unwrap())
|
||||
.bind(¶ms.surface_light.as_ref().unwrap())
|
||||
.bind(¶ms.surface_dark.as_ref().unwrap())
|
||||
.bind(¶ms.text_primary_light.as_ref().unwrap())
|
||||
.bind(¶ms.text_primary_dark.as_ref().unwrap())
|
||||
.bind(¶ms.text_secondary_light.as_ref().unwrap())
|
||||
.bind(¶ms.text_secondary_dark.as_ref().unwrap())
|
||||
.bind(¶ms.border_light.as_ref().unwrap())
|
||||
.bind(¶ms.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())]);
|
||||
}
|
||||
}
|
||||
+101
@@ -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", ¶ms).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", ¶ms).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", ¶ms).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", ¶ms).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(¶ms.key)
|
||||
.bind(¶ms.label)
|
||||
.bind(¶ms.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
Reference in New Issue
Block a user