Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
Reference in New Issue
Block a user