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

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,20 @@
[package]
name = "nomifun-common"
version.workspace = true
edition.workspace = true
[dependencies]
thiserror.workspace = true
serde = { workspace = true }
serde_json.workspace = true
uuid.workspace = true
aes-gcm.workspace = true
async-trait.workspace = true
axum.workspace = true
base64.workspace = true
getrandom.workspace = true
semver.workspace = true
tracing.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt"] }
@@ -0,0 +1,9 @@
fn main() {
// `channel::channel()` bakes `NOMI_CHANNEL` into this crate via
// `option_env!`. Cargo does not track env vars read by `option_env!`, so
// without this a channel switch (stable ⇄ dev) would NOT recompile and the
// old channel would persist until a manual `cargo clean`. Telling cargo to
// rerun this build script when the var changes marks the crate dirty and
// forces the recompile that picks up the new channel.
println!("cargo:rerun-if-env-changed=NOMI_CHANNEL");
}
@@ -0,0 +1,125 @@
//! JSON key case conversion.
//!
//! Third-party payloads (notably the ACP SDK) serialise to camelCase,
//! but our frontend contract — and every other response shape we own —
//! is snake_case. Instead of maintaining a parallel typed mirror for
//! each external struct, we deep-walk the produced `serde_json::Value`
//! and rewrite object keys before handing it to the wire.
use serde_json::{Map, Value};
/// Convert a single identifier from camelCase / PascalCase to snake_case.
///
/// Already-snake_case strings pass through unchanged. Leading acronyms
/// like `MCPServer` become `mcp_server`; trailing numeric runs
/// (`sessionV2`) stay attached to their preceding word (`session_v2`).
/// Underscores already present are preserved without duplication.
pub fn camel_to_snake(input: &str) -> String {
let mut out = String::with_capacity(input.len() + 4);
let chars: Vec<char> = input.chars().collect();
for (i, &c) in chars.iter().enumerate() {
if c.is_ascii_uppercase() {
let prev_is_lower_or_digit = i > 0 && (chars[i - 1].is_ascii_lowercase() || chars[i - 1].is_ascii_digit());
let next_is_lower = chars.get(i + 1).is_some_and(|n| n.is_ascii_lowercase());
let prev_is_upper = i > 0 && chars[i - 1].is_ascii_uppercase();
if prev_is_lower_or_digit || (prev_is_upper && next_is_lower) {
out.push('_');
}
out.extend(c.to_lowercase());
} else {
out.push(c);
}
}
out
}
/// Recursively rewrite every object key in `value` to snake_case.
///
/// Arrays and primitives are walked but not otherwise transformed. The
/// `_meta` key emitted by the ACP SDK is left verbatim — the protocol
/// reserves it for passthrough metadata whose inner keys are not ours
/// to normalise.
pub fn normalize_keys_to_snake_case(value: &mut Value) {
match value {
Value::Object(map) => {
let converted: Map<String, Value> = std::mem::take(map)
.into_iter()
.map(|(k, mut v)| {
if k != "_meta" {
normalize_keys_to_snake_case(&mut v);
}
let new_key = if k == "_meta" { k } else { camel_to_snake(&k) };
(new_key, v)
})
.collect();
*map = converted;
}
Value::Array(items) => {
for item in items {
normalize_keys_to_snake_case(item);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn camel_to_snake_basic() {
assert_eq!(camel_to_snake("currentModelId"), "current_model_id");
assert_eq!(camel_to_snake("already_snake"), "already_snake");
assert_eq!(camel_to_snake("id"), "id");
assert_eq!(camel_to_snake(""), "");
}
#[test]
fn camel_to_snake_acronyms() {
assert_eq!(camel_to_snake("MCPServer"), "mcp_server");
assert_eq!(camel_to_snake("sessionV2"), "session_v2");
assert_eq!(camel_to_snake("URLPath"), "url_path");
}
#[test]
fn normalize_nested_object() {
let mut v = json!({
"currentModeId": "yolo",
"availableModes": [
{ "id": "a", "nameLabel": "A" },
{ "id": "b", "nameLabel": "B" }
]
});
normalize_keys_to_snake_case(&mut v);
assert_eq!(
v,
json!({
"current_mode_id": "yolo",
"available_modes": [
{ "id": "a", "name_label": "A" },
{ "id": "b", "name_label": "B" }
]
})
);
}
#[test]
fn normalize_preserves_meta_content() {
let mut v = json!({
"availableModes": [],
"_meta": { "keepThisAsIs": true }
});
normalize_keys_to_snake_case(&mut v);
assert_eq!(v["_meta"]["keepThisAsIs"], true);
assert!(v.get("available_modes").is_some());
}
#[test]
fn normalize_leaves_primitives() {
let mut v = json!([1, "two", true, null]);
normalize_keys_to_snake_case(&mut v);
assert_eq!(v, json!([1, "two", true, null]));
}
}
@@ -0,0 +1,77 @@
//! Build channel (`NOMI_CHANNEL`) — the single source of truth that lets a
//! non-stable build (e.g. `dev`) coexist with the installed stable app by
//! deriving a per-channel suffix for the data directory and OS identity.
//!
//! The channel is baked at compile time from the `NOMI_CHANNEL` env var (set by
//! the dev build script); unset means `stable`. Only the exact string `stable`
//! maps to the production data directory — every other value (including typos)
//! gets an isolated suffix, so a mis-set channel can never write into the
//! installed app's state.
/// The compile-time channel. `stable` when `NOMI_CHANNEL` is unset.
pub fn channel() -> &'static str {
option_env!("NOMI_CHANNEL").unwrap_or("stable")
}
/// Path suffix appended to the `Nomi` data-dir leaf for this channel.
/// `stable` → "" (production); anything else → "-<channel>" (isolated).
pub fn dir_suffix() -> String {
suffix_for(channel())
}
/// True only for the production (stable) channel.
pub fn is_stable() -> bool {
channel() == "stable"
}
/// Whether `channel` is a recognized channel name (vs a typo). Used by the
/// startup self-check to warn on `NOMI_CHANNEL=Dev` and friends.
pub fn is_known(channel: &str) -> bool {
matches!(channel, "stable" | "dev" | "beta" | "canary")
}
/// Pure mapping: only the exact `stable` yields the empty (production) suffix;
/// every other value is isolated under `-<channel>`.
fn suffix_for(channel: &str) -> String {
if channel == "stable" {
String::new()
} else {
format!("-{channel}")
}
}
#[cfg(test)]
mod tests {
use super::{is_known, suffix_for};
#[test]
fn stable_has_no_suffix() {
assert_eq!(suffix_for("stable"), "");
}
#[test]
fn dev_is_isolated_under_dash_dev() {
assert_eq!(suffix_for("dev"), "-dev");
}
#[test]
fn future_channels_get_their_own_suffix() {
assert_eq!(suffix_for("beta"), "-beta");
assert_eq!(suffix_for("canary"), "-canary");
}
#[test]
fn only_exact_stable_maps_to_production_dir() {
// Safety invariant: a typo must NOT silently land in the prod data dir.
assert_eq!(suffix_for("Dev"), "-Dev");
assert_ne!(suffix_for("Dev"), "");
assert_ne!(suffix_for("stable "), "");
}
#[test]
fn known_channels_recognized_typos_rejected() {
assert!(is_known("stable") && is_known("dev") && is_known("beta") && is_known("canary"));
assert!(!is_known("Dev"));
assert!(!is_known("prod"));
}
}
@@ -0,0 +1,66 @@
// --- File processing ---
pub const NOMIFUN_TIMESTAMP_SEPARATOR: &str = "_nomifun_";
pub const NOMIFUN_FILES_MARKER: &str = "[[NOMI_FILES]]";
// --- WebSocket ---
pub const HEARTBEAT_INTERVAL_MS: u64 = 30_000;
pub const HEARTBEAT_TIMEOUT_MS: u64 = 60_000;
pub const WS_CLOSE_NORMAL: u16 = 1000;
pub const WS_CLOSE_POLICY_VIOLATION: u16 = 1008;
// --- Authentication ---
pub const SESSION_EXPIRY: &str = "24h";
pub const COOKIE_NAME: &str = "nomifun-session";
pub const COOKIE_MAX_AGE_DAYS: u32 = 30;
pub const CSRF_COOKIE_NAME: &str = "nomifun-csrf-token";
pub const CSRF_HEADER_NAME: &str = "x-csrf-token";
// --- Server ---
pub const DEFAULT_HOST: &str = "127.0.0.1";
pub const REMOTE_HOST: &str = "0.0.0.0";
pub const DEFAULT_PORT: u16 = 25808;
/// Request body size limit (10 MB).
pub const BODY_LIMIT: usize = 10 * 1024 * 1024;
/// File upload size limit (30 MB).
pub const UPLOAD_MAX_SIZE: usize = 30 * 1024 * 1024;
// --- Team mode ---
/// Hard-coded backends that always support team mode, regardless of ACP capability detection.
pub const TEAM_CAPABLE_BACKENDS: &[&str] = &["claude", "codex", "gemini", "nomi", "codebuddy"];
/// Determine if an agent supports team mode based on its persisted `agent_capabilities` JSON.
///
/// Returns `true` if:
/// 1. The backend is in the hard whitelist, OR
/// 2. The `agent_capabilities` JSON contains an `mcp_capabilities` / `mcpCapabilities` / `mcp`
/// field (per ACP spec, presence of any MCP transport implies stdio support).
pub fn is_team_capable(backend: &str, agent_capabilities: Option<&serde_json::Value>) -> bool {
if TEAM_CAPABLE_BACKENDS.contains(&backend) {
return true;
}
has_mcp_capability(agent_capabilities)
}
/// Check whether `agent_capabilities` JSON declares any MCP transport.
/// Per ACP spec: stdio is the baseline; if any transport is declared, the agent supports MCP.
pub fn has_mcp_capability(agent_capabilities: Option<&serde_json::Value>) -> bool {
let Some(caps) = agent_capabilities else {
return false;
};
caps.get("mcp_capabilities")
.or_else(|| caps.get("mcpCapabilities"))
.or_else(|| caps.get("mcp"))
.is_some()
}
// --- Image processing ---
pub const SUPPORTED_IMAGE_EXTENSIONS: &[&str] = &[".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff", ".svg"];
/// Remote image download size limit (5 MB).
pub const REMOTE_IMAGE_MAX_SIZE: usize = 5 * 1024 * 1024;
pub const REMOTE_IMAGE_MAX_REDIRECTS: u32 = 5;
@@ -0,0 +1,140 @@
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use crate::error::AppError;
const NONCE_SIZE: usize = 12;
const KEY_SIZE: usize = 32;
/// Encrypt a string value using AES-256-GCM.
///
/// The key must be exactly 32 bytes. Output is base64-encoded (nonce + ciphertext + tag).
pub fn encrypt_string(plaintext: &str, key: &[u8]) -> Result<String, AppError> {
validate_key_size(key)?;
let cipher =
Aes256Gcm::new_from_slice(key).map_err(|e| AppError::Internal(format!("Failed to create cipher: {e}")))?;
let mut nonce_bytes = [0u8; NONCE_SIZE];
getrandom::getrandom(&mut nonce_bytes).map_err(|e| AppError::Internal(format!("RNG failure: {e}")))?;
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext.as_bytes())
.map_err(|e| AppError::Internal(format!("Encryption failed: {e}")))?;
let mut combined = Vec::with_capacity(NONCE_SIZE + ciphertext.len());
combined.extend_from_slice(&nonce_bytes);
combined.extend_from_slice(&ciphertext);
Ok(BASE64.encode(combined))
}
/// Decrypt an AES-256-GCM encrypted string.
///
/// The key must be exactly 32 bytes. Input is base64-encoded (nonce + ciphertext + tag).
pub fn decrypt_string(ciphertext: &str, key: &[u8]) -> Result<String, AppError> {
validate_key_size(key)?;
let cipher =
Aes256Gcm::new_from_slice(key).map_err(|e| AppError::Internal(format!("Failed to create cipher: {e}")))?;
let combined = BASE64
.decode(ciphertext)
.map_err(|e| AppError::BadRequest(format!("Invalid base64: {e}")))?;
if combined.len() < NONCE_SIZE {
return Err(AppError::BadRequest("Ciphertext too short".into()));
}
let (nonce_bytes, encrypted) = combined.split_at(NONCE_SIZE);
let nonce = Nonce::from_slice(nonce_bytes);
let plaintext = cipher
.decrypt(nonce, encrypted)
.map_err(|_| AppError::BadRequest("Decryption failed: invalid key or corrupted data".into()))?;
String::from_utf8(plaintext).map_err(|e| AppError::Internal(format!("Invalid UTF-8 in decrypted data: {e}")))
}
fn validate_key_size(key: &[u8]) -> Result<(), AppError> {
if key.len() != KEY_SIZE {
return Err(AppError::BadRequest(format!(
"AES-256 key must be exactly {KEY_SIZE} bytes, got {}",
key.len()
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn test_key() -> [u8; 32] {
[0x42; 32]
}
#[test]
fn test_roundtrip() {
let key = test_key();
let encrypted = encrypt_string("hello", &key).unwrap();
let decrypted = decrypt_string(&encrypted, &key).unwrap();
assert_eq!(decrypted, "hello");
}
#[test]
fn test_empty_string() {
let key = test_key();
let encrypted = encrypt_string("", &key).unwrap();
let decrypted = decrypt_string(&encrypted, &key).unwrap();
assert_eq!(decrypted, "");
}
#[test]
fn test_unicode() {
let key = test_key();
let encrypted = encrypt_string("你好世界", &key).unwrap();
let decrypted = decrypt_string(&encrypted, &key).unwrap();
assert_eq!(decrypted, "你好世界");
}
#[test]
fn test_wrong_key_fails() {
let key = test_key();
let encrypted = encrypt_string("hello", &key).unwrap();
let wrong_key = [0x99; 32];
assert!(decrypt_string(&encrypted, &wrong_key).is_err());
}
#[test]
fn test_nonce_randomness() {
let key = test_key();
let enc1 = encrypt_string("hello", &key).unwrap();
let enc2 = encrypt_string("hello", &key).unwrap();
assert_ne!(enc1, enc2);
}
#[test]
fn test_invalid_key_size() {
let short_key = [0u8; 16];
assert!(encrypt_string("hello", &short_key).is_err());
assert!(decrypt_string("dGVzdA==", &short_key).is_err());
}
#[test]
fn test_invalid_base64() {
let key = test_key();
assert!(decrypt_string("not-valid-base64!!!", &key).is_err());
}
#[test]
fn test_ciphertext_too_short() {
let key = test_key();
// Base64 of less than 12 bytes
let short = BASE64.encode([0u8; 5]);
assert!(decrypt_string(&short, &key).is_err());
}
}
@@ -0,0 +1,405 @@
use serde::{Deserialize, Serialize};
/// Type of AI agent backend.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AgentType {
Acp,
#[serde(rename = "openclaw-gateway")]
OpenclawGateway,
Nanobot,
Remote,
Nomi,
/// Legacy Gemini conversations. Kept solely so that historical rows
/// with `type='gemini'` remain readable in the conversation list and
/// message history. Any attempt to run the agent (send a message,
/// resume a session) returns an error — this variant has no factory
/// branch. New Gemini conversations use `AgentType::Acp` with
/// `backend='gemini'`.
Gemini,
}
impl AgentType {
pub fn display_name(&self) -> &'static str {
match self {
AgentType::Acp => "ACP",
AgentType::OpenclawGateway => "OpenClaw Gateway",
AgentType::Nanobot => "Nanobot",
AgentType::Remote => "Remote",
AgentType::Nomi => "Nomi",
AgentType::Gemini => "Gemini (legacy)",
}
}
pub fn serde_name(&self) -> &'static str {
match self {
AgentType::Acp => "acp",
AgentType::OpenclawGateway => "openclaw-gateway",
AgentType::Nanobot => "nanobot",
AgentType::Remote => "remote",
AgentType::Nomi => "nomi",
AgentType::Gemini => "gemini",
}
}
/// Native skill-discovery directories for non-ACP agent types.
///
/// ACP vendors own their skill dirs through the `agent_metadata`
/// table; this method covers the few non-ACP agent types that still
/// support native skill discovery. Returns `None` for agent types
/// that require prompt-injection instead of workspace symlinks.
///
/// `AgentType::Gemini` is intentionally absent: new Gemini
/// conversations use `AgentType::Acp` with `backend = "gemini"`, so
/// their skill dirs come from the Gemini row in the catalog.
/// Historical `AgentType::Gemini` rows cannot start a new runtime
/// (see the variant's doc comment) and therefore never reach this
/// path during workspace provisioning.
pub fn native_skills_dirs(&self) -> Option<&'static [&'static str]> {
match self {
AgentType::Nomi => Some(&[".nomi/skills"]),
AgentType::Acp
| AgentType::OpenclawGateway
| AgentType::Nanobot
| AgentType::Remote
| AgentType::Gemini => None,
}
}
/// Canonical full-auto session mode id for this agent type.
///
/// ACP agents need backend-specific mode ids, while other agent types
/// currently converge on the permissive `yolo` mode.
///
/// `backend` is the vendor label (e.g. `"claude"`, `"codex"`) used
/// only by ACP; pass `None` for non-ACP agents. This mapping is
/// duplicated in the seed of `agent_metadata.yolo_id` — code paths
/// with DB access should prefer reading that column. This function
/// is a fallback for offline / pre-hydrate callers (cron, tests).
pub fn full_auto_mode_id(&self, backend: Option<&str>) -> &'static str {
match self {
AgentType::Acp => match backend {
Some("claude") | Some("codebuddy") => "bypassPermissions",
Some("codex") => "full-access",
Some("opencode") => "build",
Some("cursor") => "agent",
_ => "yolo",
},
AgentType::Nomi
| AgentType::Gemini
| AgentType::OpenclawGateway
| AgentType::Nanobot
| AgentType::Remote => "yolo",
}
}
}
/// Runtime status of a conversation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ConversationStatus {
Pending,
Running,
Finished,
}
/// Origin of a conversation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ConversationSource {
Nomifun,
Telegram,
Lark,
Dingtalk,
Weixin,
}
/// Type discriminant for messages in a conversation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MessageType {
Text,
Tips,
ToolCall,
ToolGroup,
AgentStatus,
Permission,
AcpToolCall,
Plan,
Thinking,
AvailableCommands,
SkillSuggest,
CronTrigger,
}
/// Display position of a message in the chat UI.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MessagePosition {
Right,
Left,
Center,
Pop,
}
/// Processing status of a message.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MessageStatus {
Finish,
Pending,
Error,
Work,
}
/// LLM API protocol type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProtocolType {
#[serde(rename = "openai")]
OpenAI,
Anthropic,
Gemini,
Unknown,
}
/// Remote Agent protocol.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RemoteAgentProtocol {
OpenClaw,
ZeroClaw,
Acp,
}
/// Remote Agent authentication method.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RemoteAgentAuthType {
Bearer,
Password,
None,
}
/// Remote Agent connection status.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RemoteAgentStatus {
Unknown,
Connected,
Pending,
Error,
}
/// Reason for terminating an Agent.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentKillReason {
IdleTimeout,
/// The ACP session ended a turn with a terminal error. The conversation is
/// preserved; only the in-memory agent task is recycled before the next send
/// so a potentially desynchronised upstream session is not reused.
AgentErrorRecovery,
/// Team session is rebuilding the agent process to inject a fresh
/// `team_mcp_stdio_config`. The conversation is preserved; only the
/// in-memory ACP CLI is recycled.
TeamMcpRebuild,
/// The session's bound knowledge bases changed (a `挂载知识库` toggle, a
/// rebind, or a write-back mode switch). The agent bakes the knowledge
/// retrieval-protocol section at build time and is cached per
/// conversation, so the in-memory task is recycled to force a rebuild —
/// honoring the UI contract that a binding change "takes effect on the
/// next message". The conversation (and any persisted ACP session) is
/// preserved; the rebuilt agent resumes and re-delivers the section.
KnowledgeBindingChanged,
/// Team is being deleted; every agent process under it must be torn
/// down before the team's conversations / rows are removed.
TeamDeleted,
/// The owning conversation was deleted via `DELETE /api/conversations/{id}`.
/// The agent process must be torn down so it stops emitting stream events
/// for a conversation row that no longer exists.
ConversationDeleted,
}
/// Preview content type for document preview history.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PreviewContentType {
Markdown,
Diff,
Code,
Html,
Pdf,
Ppt,
Word,
Excel,
Image,
Url,
}
/// File change operation type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FileChangeOperation {
Create,
Modify,
Delete,
}
/// AI Agent CLI source identifier for MCP configuration sync.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum McpSource {
Claude,
Gemini,
Qwen,
Codex,
#[serde(rename = "codebuddy")]
CodeBuddy,
#[serde(rename = "opencode")]
OpenCode,
Nomi,
Nanobot,
Nomifun,
}
/// MCP server connection status.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum McpServerStatus {
Connected,
Disconnected,
Error,
Testing,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_agent_type_display_names() {
assert_eq!(AgentType::OpenclawGateway.display_name(), "OpenClaw Gateway");
assert_eq!(AgentType::Nomi.display_name(), "Nomi");
assert_eq!(AgentType::Nanobot.display_name(), "Nanobot");
assert_eq!(AgentType::Remote.display_name(), "Remote");
assert_eq!(AgentType::Acp.display_name(), "ACP");
}
#[test]
fn test_agent_type_serde_roundtrip() {
let val = AgentType::OpenclawGateway;
let json = serde_json::to_string(&val).unwrap();
assert_eq!(json, r#""openclaw-gateway""#);
let parsed: AgentType = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, val);
}
#[test]
fn test_agent_type_all_variants() {
let cases = [
(AgentType::Acp, "acp"),
(AgentType::OpenclawGateway, "openclaw-gateway"),
(AgentType::Nanobot, "nanobot"),
(AgentType::Remote, "remote"),
(AgentType::Nomi, "nomi"),
];
for (variant, expected) in cases {
let json = serde_json::to_string(&variant).unwrap();
assert_eq!(json, format!("\"{expected}\""), "serialize {variant:?}");
let parsed: AgentType = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, variant, "deserialize {expected}");
}
}
#[test]
fn test_protocol_type_openai() {
let val = ProtocolType::OpenAI;
let json = serde_json::to_string(&val).unwrap();
assert_eq!(json, r#""openai""#);
let parsed: ProtocolType = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, ProtocolType::OpenAI);
}
#[test]
fn test_conversation_status_lowercase() {
let val = ConversationStatus::Pending;
let json = serde_json::to_string(&val).unwrap();
assert_eq!(json, r#""pending""#);
}
#[test]
fn test_message_type_snake_case() {
let val = MessageType::ToolCall;
let json = serde_json::to_string(&val).unwrap();
assert_eq!(json, r#""tool_call""#);
let val = MessageType::AcpToolCall;
let json = serde_json::to_string(&val).unwrap();
assert_eq!(json, r#""acp_tool_call""#);
let val = MessageType::AgentStatus;
let json = serde_json::to_string(&val).unwrap();
assert_eq!(json, r#""agent_status""#);
}
#[test]
fn test_file_change_operation_roundtrip() {
for op in [
FileChangeOperation::Create,
FileChangeOperation::Modify,
FileChangeOperation::Delete,
] {
let json = serde_json::to_string(&op).unwrap();
let parsed: FileChangeOperation = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, op);
}
}
#[test]
fn test_mcp_source_serde_roundtrip() {
let cases = [
(McpSource::Claude, r#""claude""#),
(McpSource::Gemini, r#""gemini""#),
(McpSource::Qwen, r#""qwen""#),
(McpSource::Codex, r#""codex""#),
(McpSource::CodeBuddy, r#""codebuddy""#),
(McpSource::OpenCode, r#""opencode""#),
(McpSource::Nomi, r#""nomi""#),
(McpSource::Nanobot, r#""nanobot""#),
(McpSource::Nomifun, r#""nomifun""#),
];
for (variant, expected_json) in cases {
let json = serde_json::to_string(&variant).unwrap();
assert_eq!(json, expected_json, "serialize {variant:?}");
let parsed: McpSource = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, variant, "deserialize {expected_json}");
}
}
#[test]
fn test_mcp_server_status_serde_roundtrip() {
let cases = [
(McpServerStatus::Connected, r#""connected""#),
(McpServerStatus::Disconnected, r#""disconnected""#),
(McpServerStatus::Error, r#""error""#),
(McpServerStatus::Testing, r#""testing""#),
];
for (variant, expected_json) in cases {
let json = serde_json::to_string(&variant).unwrap();
assert_eq!(json, expected_json, "serialize {variant:?}");
let parsed: McpServerStatus = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, variant, "deserialize {expected_json}");
}
}
#[test]
fn agent_type_full_auto_mode_id_supports_non_acp_agents() {
assert_eq!(AgentType::Acp.full_auto_mode_id(Some("codex")), "full-access");
assert_eq!(AgentType::Acp.full_auto_mode_id(Some("claude")), "bypassPermissions");
assert_eq!(AgentType::Acp.full_auto_mode_id(Some("gemini")), "yolo");
assert_eq!(AgentType::Acp.full_auto_mode_id(None), "yolo");
assert_eq!(AgentType::Nomi.full_auto_mode_id(None), "yolo");
assert_eq!(AgentType::Remote.full_auto_mode_id(None), "yolo");
}
}
@@ -0,0 +1,388 @@
use std::path::{Component, Path};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::Serialize;
use serde_json::{Value, json};
/// Application-level error with HTTP status code mapping.
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("Not found: {0}")]
NotFound(String),
#[error("Bad request: {0}")]
BadRequest(String),
#[error("Unauthorized: {0}")]
Unauthorized(String),
#[error("Forbidden: {0}")]
Forbidden(String),
#[error("Conflict: {0}")]
Conflict(String),
#[error("Rate limited")]
RateLimited,
#[error("Internal error: {0}")]
Internal(String),
#[error("Bad gateway: {0}")]
BadGateway(String),
#[error("Request timeout: {0}")]
Timeout(String),
#[error("Unprocessable entity: {0}")]
UnprocessableEntity(String),
/// The conversation exists but is archived and cannot be operated on.
/// Example: legacy Gemini runtime conversations after the runtime was
/// removed — the row stays readable (list + history) but send_message /
/// resume should 410 Gone with this code so the client renders a
/// dedicated "this conversation is archived" UI instead of a generic
/// bad-request banner.
#[error("Conversation archived: {0}")]
ConversationArchived(String),
#[error(
"Workspace path contains a directory name that begins or ends with whitespace: {0}. Rename the affected directory so its name does not begin or end with whitespace."
)]
WorkspacePathEdgeWhitespace(String),
#[error(
"Workspace path contains a directory name that begins or ends with whitespace and cannot be used for send or warmup: {0}. Rename the affected directory, then update this conversation or task."
)]
WorkspacePathEdgeWhitespaceRuntimeUnsupported(String),
}
/// Internal error response body matching the `ErrorResponse` format from `nomifun-api-types`.
#[derive(Serialize)]
struct ErrorBody {
success: bool,
error: String,
code: String,
#[serde(skip_serializing_if = "Option::is_none")]
details: Option<Value>,
}
impl AppError {
/// HTTP status code for this error variant.
pub fn status_code(&self) -> StatusCode {
match self {
Self::NotFound(_) => StatusCode::NOT_FOUND,
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
Self::Unauthorized(_) => StatusCode::UNAUTHORIZED,
Self::Forbidden(_) => StatusCode::FORBIDDEN,
Self::Conflict(_) => StatusCode::CONFLICT,
Self::RateLimited => StatusCode::TOO_MANY_REQUESTS,
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::BadGateway(_) => StatusCode::BAD_GATEWAY,
Self::Timeout(_) => StatusCode::BAD_GATEWAY,
Self::UnprocessableEntity(_) => StatusCode::UNPROCESSABLE_ENTITY,
Self::ConversationArchived(_) => StatusCode::GONE,
Self::WorkspacePathEdgeWhitespace(_) => StatusCode::BAD_REQUEST,
Self::WorkspacePathEdgeWhitespaceRuntimeUnsupported(_) => StatusCode::BAD_REQUEST,
}
}
/// Machine-readable error code string.
pub fn error_code(&self) -> &'static str {
match self {
Self::NotFound(_) => "NOT_FOUND",
Self::BadRequest(_) => "BAD_REQUEST",
Self::Unauthorized(_) => "UNAUTHORIZED",
Self::Forbidden(message) => {
if message.contains("outside the allowed sandbox") {
"PATH_OUTSIDE_SANDBOX"
} else {
"FORBIDDEN"
}
}
Self::Conflict(_) => "CONFLICT",
Self::RateLimited => "RATE_LIMITED",
Self::Internal(_) => "INTERNAL_ERROR",
Self::BadGateway(_) => "BAD_GATEWAY",
Self::Timeout(_) => "TIMEOUT",
Self::UnprocessableEntity(_) => "UNPROCESSABLE_ENTITY",
Self::ConversationArchived(_) => "CONVERSATION_ARCHIVED",
Self::WorkspacePathEdgeWhitespace(_) => "WORKSPACE_PATH_EDGE_WHITESPACE_UNSUPPORTED",
Self::WorkspacePathEdgeWhitespaceRuntimeUnsupported(_) => {
"WORKSPACE_PATH_EDGE_WHITESPACE_RUNTIME_UNSUPPORTED"
}
}
}
/// Structured error metadata for clients that need stable machine-readable
/// context in addition to the top-level error code.
pub fn error_details(&self) -> Option<Value> {
match self {
Self::WorkspacePathEdgeWhitespace(path) => Some(workspace_path_whitespace_details(path, "create")),
Self::WorkspacePathEdgeWhitespaceRuntimeUnsupported(path) => {
Some(workspace_path_whitespace_details(path, "runtime"))
}
_ => None,
}
}
}
fn workspace_path_whitespace_details(path: &str, operation: &str) -> Value {
json!({
"field": "workspace",
"workspace_path": path,
"offending_segments": workspace_path_edge_whitespace_segments(Path::new(path)),
"operation": operation,
})
}
/// Return true when any normal directory/file name component in `path` is
/// pathological: it begins or ends with a Unicode whitespace character, or
/// consists entirely of whitespace.
///
/// Interior whitespace ("Application Support", "My Project") is allowed —
/// every process-spawn pipeline in this repo passes the workspace as a
/// discrete argument (`Command::current_dir`, PTY `cwd`, ACP session JSON),
/// which is whitespace-safe, and the per-user data dir on macOS always
/// contains "Application Support". Edge whitespace stays banned: Win32
/// strips trailing spaces on path lookup so such directories break
/// round-tripping, and leading/all-whitespace names are indistinguishable
/// in any UI.
pub fn workspace_path_has_edge_whitespace_segment(path: &Path) -> bool {
path.components().any(|component| match component {
Component::Normal(segment) => segment_has_edge_whitespace(&segment.to_string_lossy()),
_ => false,
})
}
fn segment_has_edge_whitespace(segment: &str) -> bool {
let trimmed = segment.trim();
trimmed.len() != segment.len() || trimmed.is_empty()
}
fn workspace_path_edge_whitespace_segments(path: &Path) -> Vec<String> {
path.components()
.filter_map(|component| match component {
Component::Normal(segment) => {
let value = segment.to_string_lossy().to_string();
if segment_has_edge_whitespace(&value) { Some(value) } else { None }
}
_ => None,
})
.collect()
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = self.status_code();
let body = ErrorBody {
success: false,
error: self.to_string(),
code: self.error_code().to_owned(),
details: self.error_details(),
};
(status, axum::Json(body)).into_response()
}
}
/// Wrap an error to display its full `source()` chain as "outer: inner1: inner2" in a single log line.
pub struct ErrorChain<'a>(pub &'a (dyn std::error::Error + 'static));
impl std::fmt::Display for ErrorChain<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)?;
let mut src = self.0.source();
while let Some(inner) = src {
write!(f, ": {inner}")?;
src = inner.source();
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::to_bytes;
#[test]
fn test_status_codes() {
assert_eq!(AppError::NotFound("x".into()).status_code(), StatusCode::NOT_FOUND);
assert_eq!(AppError::BadRequest("x".into()).status_code(), StatusCode::BAD_REQUEST);
assert_eq!(
AppError::Unauthorized("x".into()).status_code(),
StatusCode::UNAUTHORIZED
);
assert_eq!(AppError::Forbidden("x".into()).status_code(), StatusCode::FORBIDDEN);
assert_eq!(AppError::Conflict("x".into()).status_code(), StatusCode::CONFLICT);
assert_eq!(AppError::RateLimited.status_code(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(
AppError::Internal("x".into()).status_code(),
StatusCode::INTERNAL_SERVER_ERROR
);
assert_eq!(AppError::BadGateway("x".into()).status_code(), StatusCode::BAD_GATEWAY);
assert_eq!(AppError::Timeout("x".into()).status_code(), StatusCode::BAD_GATEWAY);
assert_eq!(
AppError::UnprocessableEntity("x".into()).status_code(),
StatusCode::UNPROCESSABLE_ENTITY
);
assert_eq!(
AppError::WorkspacePathEdgeWhitespace("x".into()).status_code(),
StatusCode::BAD_REQUEST
);
assert_eq!(
AppError::WorkspacePathEdgeWhitespaceRuntimeUnsupported("x".into()).status_code(),
StatusCode::BAD_REQUEST
);
}
#[test]
fn test_error_codes() {
assert_eq!(AppError::NotFound("x".into()).error_code(), "NOT_FOUND");
assert_eq!(AppError::BadRequest("x".into()).error_code(), "BAD_REQUEST");
assert_eq!(AppError::Unauthorized("x".into()).error_code(), "UNAUTHORIZED");
assert_eq!(AppError::Forbidden("x".into()).error_code(), "FORBIDDEN");
assert_eq!(
AppError::Forbidden("path '/tmp/x' is outside the allowed sandbox".into()).error_code(),
"PATH_OUTSIDE_SANDBOX"
);
assert_eq!(AppError::Conflict("x".into()).error_code(), "CONFLICT");
assert_eq!(AppError::RateLimited.error_code(), "RATE_LIMITED");
assert_eq!(AppError::Internal("x".into()).error_code(), "INTERNAL_ERROR");
assert_eq!(AppError::BadGateway("x".into()).error_code(), "BAD_GATEWAY");
assert_eq!(AppError::Timeout("x".into()).error_code(), "TIMEOUT");
assert_eq!(
AppError::UnprocessableEntity("x".into()).error_code(),
"UNPROCESSABLE_ENTITY"
);
assert_eq!(
AppError::WorkspacePathEdgeWhitespace("x".into()).error_code(),
"WORKSPACE_PATH_EDGE_WHITESPACE_UNSUPPORTED"
);
assert_eq!(
AppError::WorkspacePathEdgeWhitespaceRuntimeUnsupported("x".into()).error_code(),
"WORKSPACE_PATH_EDGE_WHITESPACE_RUNTIME_UNSUPPORTED"
);
}
#[test]
fn test_error_display() {
assert_eq!(AppError::NotFound("user 123".into()).to_string(), "Not found: user 123");
assert_eq!(AppError::RateLimited.to_string(), "Rate limited");
}
#[test]
fn test_into_response_status() {
let resp = AppError::NotFound("test".into()).into_response();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_into_response_body_format() {
let resp = AppError::NotFound("user 42".into()).into_response();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["success"], false);
assert_eq!(json["error"], "Not found: user 42");
assert_eq!(json["code"], "NOT_FOUND");
}
#[tokio::test]
async fn test_rate_limited_response_body() {
let resp = AppError::RateLimited.into_response();
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["success"], false);
assert_eq!(json["error"], "Rate limited");
assert_eq!(json["code"], "RATE_LIMITED");
assert!(json.get("details").is_none());
}
#[tokio::test]
async fn test_workspace_whitespace_response_contains_details() {
let resp = AppError::WorkspacePathEdgeWhitespace("/tmp/Archive ".into()).into_response();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["code"], "WORKSPACE_PATH_EDGE_WHITESPACE_UNSUPPORTED");
assert_eq!(json["details"]["field"], "workspace");
assert_eq!(json["details"]["workspace_path"], "/tmp/Archive ");
assert_eq!(json["details"]["offending_segments"], serde_json::json!(["Archive "]));
assert_eq!(json["details"]["operation"], "create");
}
#[tokio::test]
async fn test_workspace_runtime_whitespace_response_contains_details() {
let resp = AppError::WorkspacePathEdgeWhitespaceRuntimeUnsupported("/tmp/Archive ".into()).into_response();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["code"], "WORKSPACE_PATH_EDGE_WHITESPACE_RUNTIME_UNSUPPORTED");
assert_eq!(json["details"]["field"], "workspace");
assert_eq!(json["details"]["workspace_path"], "/tmp/Archive ");
assert_eq!(json["details"]["offending_segments"], serde_json::json!(["Archive "]));
assert_eq!(json["details"]["operation"], "runtime");
}
#[test]
fn test_workspace_path_has_edge_whitespace_segment() {
// Interior whitespace is allowed — the macOS per-user data dir
// ("Application Support") and ordinary project names depend on it.
assert!(!workspace_path_has_edge_whitespace_segment(Path::new(
"/Users/u/Library/Application Support/NomiFun/Nomi/conversations/nomi-temp-1"
)));
assert!(!workspace_path_has_edge_whitespace_segment(Path::new("/tmp/my project")));
assert!(!workspace_path_has_edge_whitespace_segment(Path::new("/tmp/my-project")));
// Edge whitespace stays rejected: trailing, leading, all-whitespace.
assert!(workspace_path_has_edge_whitespace_segment(Path::new("/tmp/project ")));
assert!(workspace_path_has_edge_whitespace_segment(Path::new("/tmp/ project")));
assert!(workspace_path_has_edge_whitespace_segment(Path::new("/tmp/\u{3000}/x")));
assert!(workspace_path_has_edge_whitespace_segment(Path::new("/tmp/tab\t")));
}
#[test]
fn test_workspace_path_edge_whitespace_segments() {
assert_eq!(
workspace_path_edge_whitespace_segments(Path::new("/tmp/my project/ leading/Archive ")),
vec![" leading".to_owned(), "Archive ".to_owned()]
);
}
#[derive(Debug, thiserror::Error)]
#[error("inner cause")]
struct Inner;
#[derive(Debug, thiserror::Error)]
#[error("outer: {message}")]
struct Outer {
message: String,
#[source]
source: Inner,
}
#[test]
fn test_error_chain_single_error() {
let err = AppError::NotFound("x".into());
assert_eq!(format!("{}", ErrorChain(&err)), err.to_string());
}
#[test]
fn test_error_chain_nested() {
let err = Outer {
message: "boom".into(),
source: Inner,
};
assert_eq!(format!("{}", ErrorChain(&err)), "outer: boom: inner cause");
}
}
@@ -0,0 +1,286 @@
//! Factory reset: arm a marker file, then perform the wipe early on the *next*
//! boot — before the DB pool opens or any background loop starts.
//!
//! Why arm-then-reboot instead of wiping in place: the `SqlitePool` is cloned
//! across every service, many background loops (AutoWork persistent loop, cron,
//! channel orchestrator, knowledge resume, companion service, IDMM …) write to the DB
//! continuously and there is no global "pause all" switch, and on Windows an
//! open connection handle blocks deleting the `.db` file. Doing the wipe at the
//! very start of boot — after `acquire_server_lock` but before `init_database`
//! — sidesteps all of that: we hold the exclusive lock, no pool is open, and no
//! loop is running.
//!
//! Flow:
//! 1. `POST /api/system/factory-reset` → [`write_marker`]
//! 2. Frontend relaunches the desktop shell.
//! 3. Next boot → [`apply_pending_reset`] deletes the DB family + derived data
//! and clears the marker; `init_database` then recreates a fresh schema.
use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::error::AppError;
use crate::timestamp::now_ms;
/// Marker file under the data dir. Its presence means a reset is pending.
pub const RESET_MARKER_FILE: &str = "factory-reset.pending";
/// Scope of a factory reset. Only `Full` exists today; the enum leaves room to
/// add a DB-only variant later without changing the marker format.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ResetScope {
/// Wipe the database AND derived on-disk data (true factory reset).
#[default]
Full,
}
/// Contents of the pending-reset marker file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResetMarker {
#[serde(default)]
pub scope: ResetScope,
/// Reserved: keep a `.factory-backup.<ts>` copy of the DB before wiping.
/// Currently always `false` (product decision: no backup, truly delete).
#[serde(default)]
pub backup: bool,
/// Epoch millis when the reset was requested (informational).
#[serde(default)]
pub requested_at: i64,
}
impl Default for ResetMarker {
fn default() -> Self {
Self { scope: ResetScope::Full, backup: false, requested_at: 0 }
}
}
impl ResetMarker {
/// Build a marker stamped with the current time.
pub fn new(scope: ResetScope) -> Self {
Self { scope, backup: false, requested_at: now_ms() }
}
}
/// SQLite DB family (relative to data_dir). Mirrors `nomifun-db`'s on-disk
/// layout: the database, its WAL sidecars, and the cross-process migrate lock.
/// `server.lock` / `server.lock.info` are intentionally excluded — this process
/// holds that lock for its whole lifetime.
///
/// Order matters for partial-failure safety: sidecars and the migrate lock are
/// removed first and the main `.db` last, so a half-completed wipe never leaves
/// a freshly created DB paired with a stale `-wal`/`-shm`.
const DB_FAMILY: &[&str] = &[
"nomifun-backend.db-wal",
"nomifun-backend.db-shm",
"nomifun-backend.db.migrate.lock",
"nomifun-backend.db",
];
/// Derived data directories (relative to data_dir) wiped on a `Full` reset.
/// Names are kept as literals here so `nomifun-common` need not depend on the
/// domain crates; they mirror those crates' constants:
/// `knowledge` → `nomifun_knowledge::KB_MANAGED_REL_DIR`
/// `companion` → `nomifun_companion::PET_*_REL_DIR` (whole tree incl. `memory.db`)
/// `attachments` → `nomifun_requirement` `ATTACHMENTS_REL_DIR`
/// `cron` → parent of `nomifun_cron::CRON_SKILLS_REL_DIR`
/// `conversations` → per-conversation workspaces (also handled under work_dir)
/// Intentionally NOT wiped (regenerable or in-use): `logs` (tracing holds an
/// open handle), `runtime`, `bun-cache`, `bun-tmp`, `builtin-skills` (the next
/// boot re-materializes them).
const DERIVED_DIRS: &[&str] = &[
"conversations",
"attachments",
"knowledge",
"companion",
"cron",
"preview-history",
"nomi-sessions",
"nomi-health-check-sessions",
"browser-profile",
];
fn marker_path(data_dir: &Path) -> PathBuf {
data_dir.join(RESET_MARKER_FILE)
}
/// Arm a factory reset: write the marker. The actual wipe happens on next boot.
pub fn write_marker(data_dir: &Path, marker: &ResetMarker) -> Result<(), AppError> {
let json = serde_json::to_vec_pretty(marker)
.map_err(|e| AppError::Internal(format!("serialize factory-reset marker: {e}")))?;
std::fs::write(marker_path(data_dir), json)
.map_err(|e| AppError::Internal(format!("write factory-reset marker: {e}")))?;
Ok(())
}
/// Read the pending-reset marker, if any. A present-but-malformed marker is
/// treated as a default (`Full`) reset rather than silently ignored — once a
/// reset is armed it must not be skipped.
pub fn read_marker(data_dir: &Path) -> Option<ResetMarker> {
let bytes = std::fs::read(marker_path(data_dir)).ok()?;
Some(serde_json::from_slice(&bytes).unwrap_or_default())
}
/// Remove the marker file (idempotent).
pub fn clear_marker(data_dir: &Path) {
let _ = std::fs::remove_file(marker_path(data_dir));
}
/// If a reset marker is present, perform the wipe and clear the marker.
/// Returns `Ok(true)` if a reset was applied, `Ok(false)` if there was nothing
/// to do. Must be called early in boot (after the server lock is held, before
/// the database is opened).
///
/// The DB family removal is treated as fatal (it must succeed for a clean
/// reinit; at this boot stage nothing holds those handles). Derived-data
/// removal is best-effort: failures are logged and boot continues.
pub fn apply_pending_reset(data_dir: &Path, work_dir: &Path) -> Result<bool, AppError> {
let Some(marker) = read_marker(data_dir) else {
return Ok(false);
};
tracing::warn!(
target: "factory_reset",
scope = ?marker.scope,
requested_at = marker.requested_at,
"factory-reset marker found — wiping database and derived data"
);
// 1. DB family — core; must succeed.
for name in DB_FAMILY {
let path = data_dir.join(name);
remove_path_with_retry(&path).map_err(|e| {
AppError::Internal(format!("factory reset: failed to remove {}: {e}", path.display()))
})?;
}
// 2. Derived data dirs — best-effort.
let mut targets: Vec<PathBuf> = DERIVED_DIRS.iter().map(|d| data_dir.join(d)).collect();
if work_dir != data_dir {
// Conversation workspaces live under work_dir when it has been relocated
// away from data_dir.
targets.push(work_dir.join("conversations"));
}
for path in targets {
if let Err(e) = remove_path_with_retry(&path) {
tracing::warn!(
target: "factory_reset",
path = %path.display(),
error = %e,
"factory reset: could not remove derived path (continuing)"
);
}
}
// 3. Clear the marker so the next boot is normal.
clear_marker(data_dir);
tracing::warn!(target: "factory_reset", "factory reset complete — a fresh database will be created");
Ok(true)
}
/// Remove a file, directory tree, or symlink. Missing paths are a no-op. On
/// Windows, transient sharing/lock/access errors are retried with backoff
/// (mirrors `nomifun-db`'s startup file-op retry for raw OS errors 5/32/33).
fn remove_path_with_retry(path: &Path) -> std::io::Result<()> {
const MAX_ATTEMPTS: u32 = 5;
for attempt in 1..=MAX_ATTEMPTS {
let result = match std::fs::symlink_metadata(path) {
Ok(meta) if meta.is_dir() => std::fs::remove_dir_all(path),
Ok(_) => std::fs::remove_file(path),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => Err(e),
};
match result {
Ok(()) => return Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) if attempt < MAX_ATTEMPTS && is_retryable(&e) => {
std::thread::sleep(Duration::from_millis(80 * u64::from(attempt)));
}
Err(e) => return Err(e),
}
}
Ok(())
}
/// Windows transient file-op errors: 5 = access denied, 32 = sharing violation,
/// 33 = lock violation.
fn is_retryable(e: &std::io::Error) -> bool {
matches!(e.raw_os_error(), Some(5) | Some(32) | Some(33))
}
#[cfg(test)]
mod tests {
use super::*;
fn touch(path: &Path) {
std::fs::write(path, b"x").unwrap();
}
#[test]
fn no_marker_is_noop() {
let dir = std::env::temp_dir().join(format!("nomifun-fr-noop-{}", now_ms()));
std::fs::create_dir_all(&dir).unwrap();
assert_eq!(apply_pending_reset(&dir, &dir).unwrap(), false);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn full_reset_wipes_targets_keeps_logs_and_clears_marker() {
let dir = std::env::temp_dir().join(format!("nomifun-fr-full-{}", now_ms()));
std::fs::create_dir_all(&dir).unwrap();
// DB family + sidecars.
touch(&dir.join("nomifun-backend.db"));
touch(&dir.join("nomifun-backend.db-wal"));
touch(&dir.join("nomifun-backend.db-shm"));
touch(&dir.join("nomifun-backend.db.migrate.lock"));
// Derived data dirs.
for d in ["conversations", "attachments", "knowledge", "companion", "cron", "browser-profile"] {
std::fs::create_dir_all(dir.join(d)).unwrap();
touch(&dir.join(d).join("inner.txt"));
}
// Preserved dirs / our lock.
std::fs::create_dir_all(dir.join("logs")).unwrap();
touch(&dir.join("logs").join("app.log"));
std::fs::create_dir_all(dir.join("runtime")).unwrap();
touch(&dir.join("server.lock"));
write_marker(&dir, &ResetMarker::new(ResetScope::Full)).unwrap();
assert!(dir.join(RESET_MARKER_FILE).exists());
assert_eq!(apply_pending_reset(&dir, &dir).unwrap(), true);
// DB family gone.
for f in DB_FAMILY {
assert!(!dir.join(f).exists(), "{f} should be deleted");
}
// Derived data gone.
for d in ["conversations", "attachments", "knowledge", "companion", "cron", "browser-profile"] {
assert!(!dir.join(d).exists(), "{d} should be deleted");
}
// Preserved.
assert!(dir.join("logs").join("app.log").exists(), "logs must be preserved");
assert!(dir.join("runtime").exists(), "runtime must be preserved");
assert!(dir.join("server.lock").exists(), "server.lock must be preserved");
// Marker cleared → next boot is normal.
assert!(!dir.join(RESET_MARKER_FILE).exists(), "marker must be cleared");
assert_eq!(apply_pending_reset(&dir, &dir).unwrap(), false);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn malformed_marker_still_triggers_reset() {
let dir = std::env::temp_dir().join(format!("nomifun-fr-bad-{}", now_ms()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(RESET_MARKER_FILE), b"not json").unwrap();
touch(&dir.join("nomifun-backend.db"));
assert_eq!(apply_pending_reset(&dir, &dir).unwrap(), true);
assert!(!dir.join("nomifun-backend.db").exists());
assert!(!dir.join(RESET_MARKER_FILE).exists());
let _ = std::fs::remove_dir_all(&dir);
}
}
@@ -0,0 +1,87 @@
//! 由用户可见显示名(伙伴名等)派生的、文件系统安全的目录段。
//! 与纯 ASCII slug 不同:保留 CJK 与其它 Unicode 字母数字,只剔除文件系统
//! (尤其 Windows/NTFS)无法存进单个路径段的字符。
/// 段长上限(按字符数,非字节):兼顾 CJK 可读与总路径长度。调用方会前缀一个
/// 稳定唯一 id(如 seq),故截断不会造成冲突,无需 hash 后缀。
const MAX_SEGMENT_CHARS: usize = 40;
/// Windows 在路径段中禁止的字符。
const WIN_ILLEGAL: &[char] = &['<', '>', ':', '"', '/', '\\', '|', '?', '*'];
/// 把显示名净化为「单个」文件系统安全目录段(不含 seq/id 前缀)。无任何安全字符
/// 残留时返回空串——调用方应在该情形退化为仅用稳定 id/seq。
///
/// 规则:Windows 非法字符 + ASCII 控制字符 + 内部空白 → `_`;连续 `_` 折叠为一;
/// 首尾的 `_`、`.`、空白裁掉(Windows 会静默吞掉结尾的点/空格);按字符数上限
/// `MAX_SEGMENT_CHARS` 截断。CJK 与其它 Unicode 字母数字原样保留。
///
/// 注:Windows 保留名(CON/NUL/…)不在此处理——调用方的 seq 数字前缀
/// (如 `1_con`)天然使整段不再是保留名。
pub fn sanitize_dir_segment(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut prev_underscore = false;
for ch in name.chars() {
let mapped = if ch.is_control() || ch.is_whitespace() || WIN_ILLEGAL.contains(&ch) {
'_'
} else {
ch
};
if mapped == '_' {
if prev_underscore {
continue; // 折叠连续下划线
}
prev_underscore = true;
} else {
prev_underscore = false;
}
out.push(mapped);
}
let trim = |s: &str| {
s.trim_matches(|c: char| c == '_' || c == '.' || c.is_whitespace())
.to_string()
};
let trimmed = trim(&out);
let capped: String = trimmed.chars().take(MAX_SEGMENT_CHARS).collect();
trim(&capped)
}
#[cfg(test)]
mod tests {
use super::sanitize_dir_segment as s;
#[test]
fn keeps_cjk() {
assert_eq!(s("毛球"), "毛球");
}
#[test]
fn replaces_windows_illegal_and_collapses() {
assert_eq!(s("a/b\\c:d"), "a_b_c_d");
}
#[test]
fn internal_whitespace_to_underscore() {
assert_eq!(s("My Bot"), "My_Bot");
}
#[test]
fn trims_edge_dots_spaces_underscores() {
assert_eq!(s(" ..毛球.. "), "毛球");
}
#[test]
fn neutralizes_traversal() {
assert_eq!(s("../etc"), "etc");
}
#[test]
fn all_illegal_returns_empty() {
assert_eq!(s("///"), "");
assert_eq!(s(" "), "");
}
#[test]
fn control_chars_to_underscore() {
assert_eq!(s("a\u{0007}b"), "a_b");
}
#[test]
fn caps_at_40_chars_by_char_count() {
let name = "".repeat(50);
assert_eq!(s(&name).chars().count(), 40);
}
}
@@ -0,0 +1,55 @@
//! Cross-crate lifecycle hook traits.
//!
//! Hooks defined here let lower-layer crates (e.g. `nomifun-ai-agent`,
//! `nomifun-cron`) react to events owned by higher-layer crates (e.g.
//! `nomifun-conversation`) without forming a dependency cycle.
use async_trait::async_trait;
/// Notified when a conversation row is deleted via
/// `ConversationService::delete`.
///
/// Implementors are responsible for cleaning up their per-conversation state
/// (kill agent processes, drop cron jobs, etc.). Hooks run sequentially in
/// registration order; failures must be logged inside the hook and not
/// propagated.
#[async_trait]
pub trait OnConversationDelete: Send + Sync {
async fn on_conversation_deleted(&self, conversation_id: i64);
}
/// Notified when a terminal session row is deleted via
/// `TerminalService::delete`.
///
/// Mirrors [`OnConversationDelete`] for the terminal domain. Lets lower-layer
/// crates react to a terminal going away without `nomifun-terminal` depending
/// on them (e.g. `nomifun-requirement` clears the dual-domain
/// `owner_session_id`/`owner_kind` of requirements owned by a `term_*` session,
/// which has no FK to cascade — spec §9.B).
///
/// Implementors are responsible for cleaning up their per-terminal state. Hooks
/// run sequentially in registration order; failures must be logged inside the
/// hook and not propagated.
#[async_trait]
pub trait OnTerminalDelete: Send + Sync {
async fn on_terminal_deleted(&self, terminal_id: i64);
}
/// Creates a tracked requirement from an inbound channel message (the opt-in
/// IM → requirement pipeline). Lets `nomifun-channel` file a message as a
/// requirement without depending on `nomifun-requirement`; the concrete
/// implementor (in `nomifun-requirement`) delegates to `RequirementService`.
/// Creating a `Pending` requirement is enough — AutoWork is woken to execute it.
#[async_trait]
pub trait RequirementCreator: Send + Sync {
/// Create a Pending requirement. `tag` is the board column to file under
/// (e.g. "inbox"); `created_by` records the origin (e.g. "channel:slack").
/// Returns the new requirement's id on success.
async fn create_from_message(
&self,
title: &str,
content: &str,
tag: &str,
created_by: &str,
) -> Result<String, String>;
}
@@ -0,0 +1,131 @@
use crate::timestamp::now_ms;
use uuid::Uuid;
/// Lowercase Crockford-style base32 alphabet (drops `i`/`l`/`o`/`u` so the id
/// stays unambiguous when read aloud or copied by hand), kept in ascending
/// ASCII order. Because the alphabet itself is sorted, a fixed-width
/// big-endian encoding sorts lexicographically by the integer it encodes —
/// which is what lets the short id stand in for the retired `seq` ordering.
const SHORT_ID_ALPHABET: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz";
/// Bits of millisecond timestamp retained (45 bits stays monotonic well past
/// year 3000) and the base32 char count that encodes them (45 / 5).
const TIME_BITS: u32 = 45;
const TIME_CHARS: usize = 9;
/// Random bits appended after the timestamp, and their base32 char count
/// (35 / 5). 35 bits per millisecond keeps cross-device collisions negligible.
const RAND_BITS: u32 = 35;
const RAND_CHARS: usize = 7;
/// Encode the low `chars * 5` bits of `value` as `chars` base32 characters,
/// most-significant character first (big-endian), so the resulting text sorts
/// in the same order as `value`.
fn encode_base32(mut value: u64, chars: usize) -> String {
let mut buf = vec![0u8; chars];
for slot in buf.iter_mut().rev() {
*slot = SHORT_ID_ALPHABET[(value & 0b1_1111) as usize];
value >>= 5;
}
// Every byte came from SHORT_ID_ALPHABET, which is ASCII.
String::from_utf8(buf).expect("base32 alphabet is valid ASCII")
}
/// Generate a full UUID v7 string (36 chars).
///
/// For non-entity randomness only: auth tokens / credentials and the random
/// suffix of composite idempotency keys. Entity IDs must use
/// [`generate_prefixed_id`] instead.
pub fn generate_id() -> String {
Uuid::now_v7().to_string()
}
/// Generate a prefixed entity ID: `{prefix}_{short}` (e.g. `conv_0fh3k…`,
/// `msg_0fh3k…`).
///
/// The 16-char body is a sortable short id — a 45-bit millisecond timestamp
/// (9 base32 chars) followed by 35 random bits (7 base32 chars). It is
/// lexicographically time-ordered (so it carries the ordering the retired
/// `seq` track used to provide) and globally unique for safe cross-device
/// exit, but roughly half the length of the former UUIDv7 tail so that
/// "display == primary key" stays human-readable wherever an id is shown.
///
/// The single minting convention for every entity ID across the backend —
/// see the prefix table in
/// `docs/superpowers/specs/2026-06-11-entity-seq-design.md`. Its frontend
/// mirror is `prefixedId` in `ui/src/common/utils/prefixedId.ts`; the two
/// implementations MUST stay bit-for-bit aligned so ids minted on either side
/// interleave and sort identically.
pub fn generate_prefixed_id(prefix: &str) -> String {
let ms = (now_ms() as u64) & ((1u64 << TIME_BITS) - 1);
let mut rand_bytes = [0u8; 8];
getrandom::getrandom(&mut rand_bytes).expect("OS entropy source unavailable");
let rand = u64::from_le_bytes(rand_bytes) & ((1u64 << RAND_BITS) - 1);
format!("{prefix}_{}{}", encode_base32(ms, TIME_CHARS), encode_base32(rand, RAND_CHARS))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
#[test]
fn test_generate_id_is_valid_uuid() {
let id = generate_id();
assert!(Uuid::parse_str(&id).is_ok());
}
#[test]
fn test_generate_id_is_v7() {
let id = generate_id();
let uuid = Uuid::parse_str(&id).unwrap();
assert_eq!(uuid.get_version_num(), 7);
}
#[test]
fn test_generate_prefixed_id_format() {
let id = generate_prefixed_id("msg");
assert!(id.starts_with("msg_"));
let body = &id["msg_".len()..];
assert_eq!(body.len(), TIME_CHARS + RAND_CHARS);
assert!(
body.bytes().all(|b| SHORT_ID_ALPHABET.contains(&b)),
"body {body} must use only the short-id alphabet"
);
}
#[test]
fn test_prefixed_id_uniqueness() {
let ids: HashSet<String> = (0..10_000).map(|_| generate_prefixed_id("x")).collect();
assert_eq!(ids.len(), 10_000);
}
#[test]
fn test_generate_id_uniqueness() {
let ids: HashSet<String> = (0..1000).map(|_| generate_id()).collect();
assert_eq!(ids.len(), 1000);
}
#[test]
fn test_prefixed_id_time_ordering() {
// The timestamp is the high-order, big-endian prefix of the body, so a
// later mint sorts after an earlier one once the millisecond differs.
let earlier = generate_prefixed_id("c");
std::thread::sleep(std::time::Duration::from_millis(2));
let later = generate_prefixed_id("c");
assert!(later > earlier, "{later} should sort after {earlier}");
}
#[test]
fn test_generate_id_time_ordering() {
let id1 = generate_id();
let id2 = generate_id();
assert!(id2 >= id1);
}
#[test]
fn test_long_prefix() {
let prefix = "a".repeat(1000);
let id = generate_prefixed_id(&prefix);
assert!(id.starts_with(&prefix));
}
}
@@ -0,0 +1,30 @@
//! Shared primitives: error types, enums, ID generation, crypto, timestamps, and pagination.
pub mod channel;
pub mod constants;
mod case_convert;
mod crypto;
mod enums;
mod error;
pub mod factory_reset;
mod fsname;
mod hooks;
mod id;
mod pagination;
mod timestamp;
mod types;
pub use case_convert::{camel_to_snake, normalize_keys_to_snake_case};
pub use crypto::{decrypt_string, encrypt_string};
pub use enums::{
AgentKillReason, AgentType, ConversationSource, ConversationStatus, FileChangeOperation, McpServerStatus,
McpSource, MessagePosition, MessageStatus, MessageType, PreviewContentType, ProtocolType, RemoteAgentAuthType,
RemoteAgentProtocol, RemoteAgentStatus,
};
pub use error::{AppError, ErrorChain, workspace_path_has_edge_whitespace_segment};
pub use fsname::sanitize_dir_segment;
pub use hooks::{OnConversationDelete, OnTerminalDelete, RequirementCreator};
pub use id::{generate_id, generate_prefixed_id};
pub use pagination::PaginatedResult;
pub use timestamp::{TimestampMs, now_ms};
pub use types::{CommandSpec, Confirmation, ConfirmationOption, EnvVar, ProviderWithModel};
@@ -0,0 +1,49 @@
use serde::{Deserialize, Serialize};
/// Universal paginated result for list APIs.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaginatedResult<T> {
pub items: Vec<T>,
pub total: u64,
pub has_more: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_serialize_snake_case() {
let result = PaginatedResult {
items: vec![1, 2, 3],
total: 10,
has_more: true,
};
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["items"], serde_json::json!([1, 2, 3]));
assert_eq!(json["total"], 10);
assert_eq!(json["has_more"], true);
}
#[test]
fn test_empty_result() {
let result: PaginatedResult<String> = PaginatedResult {
items: vec![],
total: 0,
has_more: false,
};
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["items"], serde_json::json!([]));
assert_eq!(json["total"], 0);
assert_eq!(json["has_more"], false);
}
#[test]
fn test_deserialize() {
let json = r#"{"items":[1,2],"total":5,"has_more":true}"#;
let result: PaginatedResult<i32> = serde_json::from_str(json).unwrap();
assert_eq!(result.items, vec![1, 2]);
assert_eq!(result.total, 5);
assert!(result.has_more);
}
}
@@ -0,0 +1,37 @@
use std::time::{SystemTime, UNIX_EPOCH};
/// Unix timestamp in milliseconds.
pub type TimestampMs = i64;
/// Get current timestamp in milliseconds.
pub fn now_ms() -> TimestampMs {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time before Unix epoch")
.as_millis() as TimestampMs
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_now_ms_positive() {
assert!(now_ms() > 0);
}
#[test]
fn test_now_ms_reasonable_range() {
let ts = now_ms();
// After 2020-01-01 and before 2100-01-01
assert!(ts > 1_577_836_800_000);
assert!(ts < 4_102_444_800_000);
}
#[test]
fn test_monotonic() {
let ts1 = now_ms();
let ts2 = now_ms();
assert!(ts2 >= ts1);
}
}
@@ -0,0 +1,97 @@
use std::collections::HashMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// EnvVar / CommandSpec
// ---------------------------------------------------------------------------
/// A name=value environment variable pair.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnvVar {
pub name: String,
pub value: String,
}
/// A command with its arguments and environment variables.
///
/// This is the common building block shared by CLI agent spawning,
/// MCP server transports, and agent discovery types.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CommandSpec {
pub command: PathBuf,
pub args: Vec<String>,
pub env: Vec<EnvVar>,
pub cwd: Option<String>,
}
// ---------------------------------------------------------------------------
// ProviderWithModel
// ---------------------------------------------------------------------------
/// Model selection config — references a provider and a specific model.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProviderWithModel {
pub provider_id: String,
pub model: String,
pub use_model: Option<String>,
}
/// A pending tool-call confirmation item.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Confirmation {
pub id: String,
pub call_id: String,
pub title: Option<String>,
pub action: Option<String>,
pub description: String,
pub command_type: Option<String>,
pub options: Vec<ConfirmationOption>,
}
/// A single option within a confirmation dialog.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfirmationOption {
pub label: String,
pub value: serde_json::Value,
pub params: Option<HashMap<String, String>>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_provider_with_model_serde() {
let p = ProviderWithModel {
provider_id: "openai-1".into(),
model: "gpt-4".into(),
use_model: Some("gpt-4-turbo".into()),
};
let json = serde_json::to_value(&p).unwrap();
assert_eq!(json["provider_id"], "openai-1");
assert_eq!(json["model"], "gpt-4");
assert_eq!(json["use_model"], "gpt-4-turbo");
}
#[test]
fn test_confirmation_serde() {
let c = Confirmation {
id: "c1".into(),
call_id: "call1".into(),
title: Some("Run command?".into()),
action: None,
description: "Execute shell command".into(),
command_type: Some("bash".into()),
options: vec![ConfirmationOption {
label: "Allow".into(),
value: serde_json::json!(true),
params: None,
}],
};
let json = serde_json::to_value(&c).unwrap();
assert_eq!(json["call_id"], "call1");
assert_eq!(json["command_type"], "bash");
}
}
@@ -0,0 +1,202 @@
use nomifun_common::*;
// --- ID generation ---
#[test]
fn test_generate_id_returns_uuid() {
let id = generate_id();
assert_eq!(id.len(), 36); // UUID string length
assert!(id.contains('-'));
}
#[test]
fn test_generate_prefixed_id_has_prefix() {
let id = generate_prefixed_id("cron");
assert!(id.starts_with("cron_"));
}
#[test]
fn test_generate_prefixed_id_is_prefix_plus_short_id() {
// Entity ID convention: `{prefix}_{shortId}` — a 16-char base32 body
// (9-char millisecond timestamp + 7-char random), time-ordered and far
// shorter than the former UUIDv7 tail.
const SHORT_ID_ALPHABET: &str = "0123456789abcdefghjkmnpqrstvwxyz";
let id = generate_prefixed_id("conv");
let body = id.strip_prefix("conv_").expect("prefix with underscore separator");
assert_eq!(body.len(), 16);
assert!(
body.chars().all(|c| SHORT_ID_ALPHABET.contains(c)),
"short-id body {body} must use only the base32 alphabet"
);
}
#[test]
fn test_prefixed_id_is_time_ordered() {
// The timestamp is the high-order prefix of the body, so a later mint
// sorts lexicographically after an earlier one.
let earlier = generate_prefixed_id("conv");
std::thread::sleep(std::time::Duration::from_millis(2));
let later = generate_prefixed_id("conv");
assert!(later > earlier, "{later} should sort after {earlier}");
}
#[test]
fn test_id_uniqueness_across_calls() {
let ids: std::collections::HashSet<String> = (0..1000).map(|_| generate_id()).collect();
assert_eq!(ids.len(), 1000);
}
#[test]
fn test_id_time_ordering() {
let id1 = generate_id();
let id2 = generate_id();
assert!(id2 >= id1, "UUID v7 should be time-ordered");
}
// --- Timestamp ---
#[test]
fn test_now_ms_returns_positive() {
assert!(now_ms() > 0);
}
#[test]
fn test_now_ms_monotonic() {
let t1 = now_ms();
let t2 = now_ms();
assert!(t2 >= t1);
}
// --- Crypto ---
#[test]
fn test_encrypt_decrypt_roundtrip() {
let key = [0xAB_u8; 32];
let encrypted = encrypt_string("hello world", &key).unwrap();
let decrypted = decrypt_string(&encrypted, &key).unwrap();
assert_eq!(decrypted, "hello world");
}
#[test]
fn test_encrypt_decrypt_empty_string() {
let key = [0xCD_u8; 32];
let encrypted = encrypt_string("", &key).unwrap();
let decrypted = decrypt_string(&encrypted, &key).unwrap();
assert_eq!(decrypted, "");
}
#[test]
fn test_encrypt_decrypt_unicode() {
let key = [0xEF_u8; 32];
let encrypted = encrypt_string("你好世界🌍", &key).unwrap();
let decrypted = decrypt_string(&encrypted, &key).unwrap();
assert_eq!(decrypted, "你好世界🌍");
}
#[test]
fn test_decrypt_wrong_key_fails() {
let key = [0x11_u8; 32];
let encrypted = encrypt_string("secret", &key).unwrap();
let wrong_key = [0x22_u8; 32];
assert!(decrypt_string(&encrypted, &wrong_key).is_err());
}
#[test]
fn test_encrypt_same_plaintext_different_ciphertext() {
let key = [0x33_u8; 32];
let e1 = encrypt_string("test", &key).unwrap();
let e2 = encrypt_string("test", &key).unwrap();
assert_ne!(e1, e2, "random nonce should produce different ciphertexts");
}
#[test]
fn test_encrypt_large_text() {
let key = [0x44_u8; 32];
let large = "x".repeat(1_000_000);
let encrypted = encrypt_string(&large, &key).unwrap();
let decrypted = decrypt_string(&encrypted, &key).unwrap();
assert_eq!(decrypted, large);
}
// --- AppError ---
#[test]
fn test_app_error_status_codes() {
use axum::http::StatusCode;
assert_eq!(AppError::NotFound("x".into()).status_code(), StatusCode::NOT_FOUND);
assert_eq!(AppError::BadRequest("x".into()).status_code(), StatusCode::BAD_REQUEST);
assert_eq!(
AppError::Unauthorized("x".into()).status_code(),
StatusCode::UNAUTHORIZED
);
assert_eq!(AppError::Forbidden("x".into()).status_code(), StatusCode::FORBIDDEN);
assert_eq!(AppError::Conflict("x".into()).status_code(), StatusCode::CONFLICT);
assert_eq!(AppError::RateLimited.status_code(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(
AppError::Internal("x".into()).status_code(),
StatusCode::INTERNAL_SERVER_ERROR
);
assert_eq!(AppError::BadGateway("x".into()).status_code(), StatusCode::BAD_GATEWAY);
assert_eq!(AppError::Timeout("x".into()).status_code(), StatusCode::BAD_GATEWAY);
}
#[test]
fn test_app_error_json_format() {
use axum::response::IntoResponse;
let resp = AppError::NotFound("user 123".into()).into_response();
assert_eq!(resp.status(), axum::http::StatusCode::NOT_FOUND);
}
// --- PaginatedResult ---
#[test]
fn test_paginated_result_serialize() {
let result = PaginatedResult {
items: vec!["a", "b"],
total: 100,
has_more: true,
};
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["has_more"], true);
assert_eq!(json["total"], 100);
}
#[test]
fn test_paginated_result_empty() {
let result: PaginatedResult<i32> = PaginatedResult {
items: vec![],
total: 0,
has_more: false,
};
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["items"], serde_json::json!([]));
}
// --- Enums ---
#[test]
fn test_enum_serde_roundtrip() {
let roundtrip_cases: Vec<(&str, AgentType)> = vec![
(r#""acp""#, AgentType::Acp),
(r#""nanobot""#, AgentType::Nanobot),
(r#""openclaw-gateway""#, AgentType::OpenclawGateway),
];
for (json_str, expected) in roundtrip_cases {
let parsed: AgentType = serde_json::from_str(json_str).unwrap();
assert_eq!(parsed, expected);
let serialized = serde_json::to_string(&expected).unwrap();
assert_eq!(serialized, json_str);
}
}
// --- Constants ---
#[test]
fn test_constants_values() {
assert_eq!(constants::DEFAULT_PORT, 25808);
assert_eq!(constants::HEARTBEAT_INTERVAL_MS, 30_000);
assert_eq!(constants::BODY_LIMIT, 10 * 1024 * 1024);
assert_eq!(constants::COOKIE_NAME, "nomifun-session");
assert!(constants::SUPPORTED_IMAGE_EXTENSIONS.contains(&".png"));
}