feat(auth): 添加异步密码哈希和验证函数
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

refactor(relay): 复用HTTP客户端和请求体序列化结果

feat(kernel): 添加获取单个审批记录的方法

fix(store): 改进SaaS连接错误分类和降级处理

docs: 更新审计文档和系统架构文档

refactor(prompt): 优化SQL查询参数化绑定

refactor(migration): 使用静态SQL和COALESCE更新配置项

feat(commands): 添加审批执行状态追踪和事件通知

chore: 更新启动脚本以支持Admin后台

fix(auth-guard): 优化授权状态管理和错误处理

refactor(db): 使用异步密码哈希函数

refactor(totp): 使用异步密码验证函数

style: 清理无用文件和注释

docs: 更新功能全景和审计文档

refactor(service): 优化HTTP客户端重用和请求处理

fix(connection): 改进SaaS不可用时的降级处理

refactor(handlers): 使用异步密码验证函数

chore: 更新依赖和工具链配置
This commit is contained in:
iven
2026-03-29 21:45:29 +08:00
parent b7ec317d2c
commit 7de294375b
34 changed files with 2041 additions and 894 deletions

0
Authorization Normal file
View File

View File

@@ -1,6 +1,6 @@
'use client'
import { useEffect, useState, useCallback, type ReactNode } from 'react'
import { useEffect, useState, useRef, useCallback, type ReactNode } from 'react'
import { useRouter } from 'next/navigation'
import { isAuthenticated, getAccount, clearAuth } from '@/lib/auth'
import { api, ApiRequestError } from '@/lib/api-client'
@@ -18,50 +18,61 @@ export function AuthGuard({ children }: AuthGuardProps) {
const [verifying, setVerifying] = useState(true)
const [connectionError, setConnectionError] = useState(false)
// Ref 跟踪授权状态,避免 useCallback 闭包捕获过时的 state
const authorizedRef = useRef(false)
// 防止并发验证RSC 导航可能触发多次 effect
const verifyingRef = useRef(false)
const verifyAuth = useCallback(async () => {
// 防止并发验证
if (verifyingRef.current) return
verifyingRef.current = true
setVerifying(true)
setConnectionError(false)
if (!isAuthenticated()) {
setVerifying(false)
verifyingRef.current = false
router.replace('/login')
return
}
// Already authorized? Skip re-verification on remount (e.g. Next.js RSC navigation)
// The token in localStorage is the source of truth; re-verify only on first mount
try {
const serverAccount = await api.auth.me()
setAccount(serverAccount)
setAuthorized(true)
authorizedRef.current = true
} catch (err) {
// Ignore abort errors — caused by navigation/SWR cancelling in-flight requests
// Keep current authorized state intact
// AbortError: 导航/SWR 取消了请求,忽略
// 如果已有授权ref 跟踪),保持不变;否则尝试 localStorage 缓存
if (err instanceof DOMException && err.name === 'AbortError') {
// If already authorized, stay authorized; otherwise fall through to retry
if (!authorized) {
// First mount was aborted — use cached account from localStorage
if (!authorizedRef.current) {
const cachedAccount = getAccount()
if (cachedAccount) {
setAccount(cachedAccount)
setAuthorized(true)
authorizedRef.current = true
}
}
return
}
// Only clear auth on actual authentication failures (401/403)
// Network errors, timeouts should NOT destroy the session
// 401/403: 真正的认证失败,清除 token
if (err instanceof ApiRequestError && (err.status === 401 || err.status === 403)) {
clearAuth()
authorizedRef.current = false
router.replace('/login')
} else {
// Transient error — show retry UI, keep token in localStorage
setConnectionError(true)
// 网络错误/超时 — 仅在未授权时显示连接错误
// 已授权的情况下忽略瞬态错误,保持当前状态
if (!authorizedRef.current) {
setConnectionError(true)
}
}
} finally {
setVerifying(false)
verifyingRef.current = false
}
}, [router, authorized])
}, [router])
useEffect(() => {
verifyAuth()

View File

@@ -823,6 +823,14 @@ impl Kernel {
approvals.iter().filter(|a| a.status == "pending").cloned().collect()
}
/// Get a single approval by ID (any status, not just pending)
///
/// Returns None if no approval with the given ID exists.
pub async fn get_approval(&self, id: &str) -> Option<ApprovalEntry> {
let approvals = self.pending_approvals.lock().await;
approvals.iter().find(|a| a.id == id).cloned()
}
/// Create a pending approval (called when a needs_approval hand is triggered)
pub async fn create_approval(&self, hand_id: String, input: serde_json::Value) -> ApprovalEntry {
let entry = ApprovalEntry {

View File

@@ -137,7 +137,8 @@ CREATE TABLE IF NOT EXISTS usage_records (
);
CREATE INDEX IF NOT EXISTS idx_usage_account ON usage_records(account_id);
CREATE INDEX IF NOT EXISTS idx_usage_time ON usage_records(created_at);
CREATE INDEX IF NOT EXISTS idx_usage_day ON usage_records((created_at::date));
-- idx_usage_day: Skipping because ::date on TIMESTAMPTZ is not IMMUTABLE
-- CREATE INDEX IF NOT EXISTS idx_usage_day ON usage_records((created_at::date));
CREATE TABLE IF NOT EXISTS relay_tasks (
id TEXT PRIMARY KEY,
@@ -163,7 +164,8 @@ CREATE INDEX IF NOT EXISTS idx_relay_status ON relay_tasks(status);
CREATE INDEX IF NOT EXISTS idx_relay_account ON relay_tasks(account_id);
CREATE INDEX IF NOT EXISTS idx_relay_provider ON relay_tasks(provider_id);
CREATE INDEX IF NOT EXISTS idx_relay_time ON relay_tasks(created_at);
CREATE INDEX IF NOT EXISTS idx_relay_day ON relay_tasks((created_at::date));
-- idx_relay_day: Skipping because ::date on TIMESTAMPTZ is not IMMUTABLE
-- CREATE INDEX IF NOT EXISTS idx_relay_day ON relay_tasks((created_at::date));
CREATE TABLE IF NOT EXISTS config_items (
id TEXT PRIMARY KEY,
@@ -318,7 +320,8 @@ CREATE TABLE IF NOT EXISTS telemetry_reports (
CREATE INDEX IF NOT EXISTS idx_telemetry_account ON telemetry_reports(account_id);
CREATE INDEX IF NOT EXISTS idx_telemetry_time ON telemetry_reports(reported_at);
CREATE INDEX IF NOT EXISTS idx_telemetry_model ON telemetry_reports(model_id);
CREATE INDEX IF NOT EXISTS idx_telemetry_day ON telemetry_reports((reported_at::date));
-- idx_telemetry_day: Skipping because ::date on TIMESTAMPTZ is not IMMUTABLE
-- CREATE INDEX IF NOT EXISTS idx_telemetry_day ON telemetry_reports((reported_at::date));
-- Refresh Token storage (single-use, JWT jti tracking)
CREATE TABLE IF NOT EXISTS refresh_tokens (

View File

@@ -14,55 +14,109 @@ pub async fn list_accounts(
let page_size = query.page_size.unwrap_or(20).min(100);
let offset = (page - 1) * page_size;
let mut where_clauses = Vec::new();
let mut params: Vec<String> = Vec::new();
let mut param_idx = 1usize;
if let Some(role) = &query.role {
where_clauses.push(format!("role = ${}", param_idx));
param_idx += 1;
params.push(role.clone());
}
if let Some(status) = &query.status {
where_clauses.push(format!("status = ${}", param_idx));
param_idx += 1;
params.push(status.clone());
}
if let Some(search) = &query.search {
where_clauses.push(format!("(username LIKE ${} OR email LIKE ${} OR display_name LIKE ${})", param_idx, param_idx + 1, param_idx + 2));
param_idx += 3;
let pattern = format!("%{}%", search);
params.push(pattern.clone());
params.push(pattern.clone());
params.push(pattern);
}
let where_sql = if where_clauses.is_empty() {
String::new()
} else {
format!("WHERE {}", where_clauses.join(" AND "))
// Static SQL per combination -- no format!() string interpolation
let (total, rows) = match (&query.role, &query.status, &query.search) {
// role + status + search
(Some(role), Some(status), Some(search)) => {
let pattern = format!("%{}%", search);
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM accounts WHERE role = $1 AND status = $2 AND (username LIKE $3 OR email LIKE $3 OR display_name LIKE $3)"
).bind(role).bind(status).bind(&pattern).fetch_one(db).await?;
let rows = sqlx::query_as::<_, AccountRow>(
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at
FROM accounts WHERE role = $1 AND status = $2 AND (username LIKE $3 OR email LIKE $3 OR display_name LIKE $3)
ORDER BY created_at DESC LIMIT $4 OFFSET $5"
).bind(role).bind(status).bind(&pattern).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?;
(total, rows)
}
// role + status
(Some(role), Some(status), None) => {
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM accounts WHERE role = $1 AND status = $2"
).bind(role).bind(status).fetch_one(db).await?;
let rows = sqlx::query_as::<_, AccountRow>(
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at
FROM accounts WHERE role = $1 AND status = $2
ORDER BY created_at DESC LIMIT $3 OFFSET $4"
).bind(role).bind(status).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?;
(total, rows)
}
// role + search
(Some(role), None, Some(search)) => {
let pattern = format!("%{}%", search);
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM accounts WHERE role = $1 AND (username LIKE $2 OR email LIKE $2 OR display_name LIKE $2)"
).bind(role).bind(&pattern).fetch_one(db).await?;
let rows = sqlx::query_as::<_, AccountRow>(
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at
FROM accounts WHERE role = $1 AND (username LIKE $2 OR email LIKE $2 OR display_name LIKE $2)
ORDER BY created_at DESC LIMIT $3 OFFSET $4"
).bind(role).bind(&pattern).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?;
(total, rows)
}
// status + search
(None, Some(status), Some(search)) => {
let pattern = format!("%{}%", search);
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM accounts WHERE status = $1 AND (username LIKE $2 OR email LIKE $2 OR display_name LIKE $2)"
).bind(status).bind(&pattern).fetch_one(db).await?;
let rows = sqlx::query_as::<_, AccountRow>(
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at
FROM accounts WHERE status = $1 AND (username LIKE $2 OR email LIKE $2 OR display_name LIKE $2)
ORDER BY created_at DESC LIMIT $3 OFFSET $4"
).bind(status).bind(&pattern).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?;
(total, rows)
}
// role only
(Some(role), None, None) => {
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM accounts WHERE role = $1"
).bind(role).fetch_one(db).await?;
let rows = sqlx::query_as::<_, AccountRow>(
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at
FROM accounts WHERE role = $1
ORDER BY created_at DESC LIMIT $2 OFFSET $3"
).bind(role).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?;
(total, rows)
}
// status only
(None, Some(status), None) => {
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM accounts WHERE status = $1"
).bind(status).fetch_one(db).await?;
let rows = sqlx::query_as::<_, AccountRow>(
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at
FROM accounts WHERE status = $1
ORDER BY created_at DESC LIMIT $2 OFFSET $3"
).bind(status).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?;
(total, rows)
}
// search only
(None, None, Some(search)) => {
let pattern = format!("%{}%", search);
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM accounts WHERE (username LIKE $1 OR email LIKE $1 OR display_name LIKE $1)"
).bind(&pattern).fetch_one(db).await?;
let rows = sqlx::query_as::<_, AccountRow>(
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at
FROM accounts WHERE (username LIKE $1 OR email LIKE $1 OR display_name LIKE $1)
ORDER BY created_at DESC LIMIT $2 OFFSET $3"
).bind(&pattern).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?;
(total, rows)
}
// no filter
(None, None, None) => {
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM accounts"
).fetch_one(db).await?;
let rows = sqlx::query_as::<_, AccountRow>(
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at
FROM accounts ORDER BY created_at DESC LIMIT $1 OFFSET $2"
).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?;
(total, rows)
}
};
let count_sql = format!("SELECT COUNT(*) as count FROM accounts {}", where_sql);
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
for p in &params {
count_query = count_query.bind(p);
}
let total: i64 = count_query.fetch_one(db).await?;
let limit_idx = param_idx;
let offset_idx = param_idx + 1;
let data_sql = format!(
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at
FROM accounts {} ORDER BY created_at DESC LIMIT ${} OFFSET ${}",
where_sql, limit_idx, offset_idx
);
let mut data_query = sqlx::query_as::<_, AccountRow>(&data_sql);
for p in &params {
data_query = data_query.bind(p);
}
let rows = data_query.bind(page_size as i64).bind(offset as i64).fetch_all(db).await?;
let items: Vec<serde_json::Value> = rows
.into_iter()
.map(|r| {
@@ -102,30 +156,26 @@ pub async fn update_account(
req: &UpdateAccountRequest,
) -> SaasResult<serde_json::Value> {
let now = chrono::Utc::now().to_rfc3339();
let mut updates = Vec::new();
let mut params: Vec<String> = Vec::new();
let mut param_idx = 1usize;
if let Some(ref v) = req.display_name { updates.push(format!("display_name = ${}", param_idx)); param_idx += 1; params.push(v.clone()); }
if let Some(ref v) = req.email { updates.push(format!("email = ${}", param_idx)); param_idx += 1; params.push(v.clone()); }
if let Some(ref v) = req.role { updates.push(format!("role = ${}", param_idx)); param_idx += 1; params.push(v.clone()); }
if let Some(ref v) = req.avatar_url { updates.push(format!("avatar_url = ${}", param_idx)); param_idx += 1; params.push(v.clone()); }
// COALESCE pattern: all updatable fields in a single static SQL.
// NULL parameters leave the column unchanged.
sqlx::query(
"UPDATE accounts SET
display_name = COALESCE($1, display_name),
email = COALESCE($2, email),
role = COALESCE($3, role),
avatar_url = COALESCE($4, avatar_url),
updated_at = $5
WHERE id = $6"
)
.bind(req.display_name.as_deref())
.bind(req.email.as_deref())
.bind(req.role.as_deref())
.bind(req.avatar_url.as_deref())
.bind(&now)
.bind(account_id)
.execute(db).await?;
if updates.is_empty() {
return get_account(db, account_id).await;
}
updates.push(format!("updated_at = ${}", param_idx));
param_idx += 1;
params.push(now.clone());
params.push(account_id.to_string());
let sql = format!("UPDATE accounts SET {} WHERE id = ${}", updates.join(", "), param_idx);
let mut query = sqlx::query(&sql);
for p in &params {
query = query.bind(p);
}
query.execute(db).await?;
get_account(db, account_id).await
}

View File

@@ -17,6 +17,9 @@ fn row_to_template(
}
}
/// Row type for agent_template queries (avoids multi-line turbofish parsing issues)
type AgentTemplateRow = (String, String, Option<String>, String, String, Option<String>, Option<String>, String, String, Option<f64>, Option<i32>, String, String, i32, String, String);
/// 创建 Agent 模板
pub async fn create_template(
db: &PgPool,
@@ -58,7 +61,7 @@ pub async fn create_template(
/// 获取单个模板
pub async fn get_template(db: &PgPool, id: &str) -> SaasResult<AgentTemplateInfo> {
let row: Option<_> = sqlx::query_as(
let row: Option<AgentTemplateRow> = sqlx::query_as(
"SELECT id, name, description, category, source, model, system_prompt,
tools, capabilities, temperature, max_tokens, visibility, status,
current_version, created_at, updated_at
@@ -70,7 +73,8 @@ pub async fn get_template(db: &PgPool, id: &str) -> SaasResult<AgentTemplateInfo
}
/// 列出模板(分页 + 过滤)
/// 使用动态参数化查询,安全拼接 WHERE 条件。
/// Static SQL + conditional filter pattern: ($N IS NULL OR col = $N).
/// When the parameter is NULL the whole OR evaluates to TRUE (no filter).
pub async fn list_templates(
db: &PgPool,
query: &AgentTemplateListQuery,
@@ -79,80 +83,35 @@ pub async fn list_templates(
let page_size = query.page_size.unwrap_or(20).min(100);
let offset = ((page - 1) * page_size) as i64;
// 动态构建参数化 WHERE 子句
let mut conditions: Vec<String> = vec!["1=1".to_string()];
let mut param_idx = 1u32;
let mut cat_bind: Option<String> = None;
let mut src_bind: Option<String> = None;
let mut vis_bind: Option<String> = None;
let mut st_bind: Option<String> = None;
if let Some(ref cat) = query.category {
param_idx += 1;
conditions.push(format!("category = ${}", param_idx));
cat_bind = Some(cat.clone());
}
if let Some(ref src) = query.source {
param_idx += 1;
conditions.push(format!("source = ${}", param_idx));
src_bind = Some(src.clone());
}
if let Some(ref vis) = query.visibility {
param_idx += 1;
conditions.push(format!("visibility = ${}", param_idx));
vis_bind = Some(vis.clone());
}
if let Some(ref st) = query.status {
param_idx += 1;
conditions.push(format!("status = ${}", param_idx));
st_bind = Some(st.clone());
}
let where_clause = conditions.join(" AND ");
// COUNT 查询: WHERE 参数绑定 ($1..$N)
let count_idx = param_idx;
let count_sql = format!(
"SELECT COUNT(*) FROM agent_templates WHERE {}",
where_clause
);
let count_limit_idx = count_idx + 1;
let count_offset_idx = count_limit_idx + 1;
let data_sql = format!(
"SELECT id, name, description, category, source, model, system_prompt,
let count_sql = "SELECT COUNT(*) FROM agent_templates WHERE ($1 IS NULL OR category = $1) AND ($2 IS NULL OR source = $2) AND ($3 IS NULL OR visibility = $3) AND ($4 IS NULL OR status = $4)";
let data_sql = "SELECT id, name, description, category, source, model, system_prompt,
tools, capabilities, temperature, max_tokens, visibility, status,
current_version, created_at, updated_at
FROM agent_templates WHERE {} ORDER BY created_at DESC LIMIT ${} OFFSET ${}",
where_clause, count_limit_idx, count_offset_idx
);
FROM agent_templates WHERE ($1 IS NULL OR category = $1) AND ($2 IS NULL OR source = $2) AND ($3 IS NULL OR visibility = $3) AND ($4 IS NULL OR status = $4) ORDER BY created_at DESC LIMIT $5 OFFSET $6";
// 构建 COUNT 查询并绑定参数
let mut count_q = sqlx::query_scalar::<_, i64>(&count_sql);
if let Some(ref v) = cat_bind { count_q = count_q.bind(v); }
if let Some(ref v) = src_bind { count_q = count_q.bind(v); }
if let Some(ref v) = vis_bind { count_q = count_q.bind(v); }
if let Some(ref v) = st_bind { count_q = count_q.bind(v); }
let total: i64 = count_q.fetch_one(db).await?;
let total: i64 = sqlx::query_scalar(count_sql)
.bind(&query.category)
.bind(&query.source)
.bind(&query.visibility)
.bind(&query.status)
.fetch_one(db).await?;
// 构建数据查询并绑定参数
let mut data_q = sqlx::query_as::<_, (
String, String, Option<String>, String, String, Option<String>, Option<String>,
String, String, Option<f64>, Option<i32>, String, String, i32, String, String
)>(&data_sql);
if let Some(ref v) = cat_bind { data_q = data_q.bind(v); }
if let Some(ref v) = src_bind { data_q = data_q.bind(v); }
if let Some(ref v) = vis_bind { data_q = data_q.bind(v); }
if let Some(ref v) = st_bind { data_q = data_q.bind(v); }
data_q = data_q.bind(page_size as i64).bind(offset);
let rows = data_q.fetch_all(db).await?;
let rows: Vec<AgentTemplateRow> = sqlx::query_as(data_sql)
.bind(&query.category)
.bind(&query.source)
.bind(&query.visibility)
.bind(&query.status)
.bind(page_size as i64)
.bind(offset)
.fetch_all(db).await?;
let items = rows.into_iter().map(row_to_template).collect();
Ok(crate::common::PaginatedResponse { items, total, page, page_size })
}
/// 更新模板
/// 使用动态参数化查询,安全拼接 SET 子句。
/// COALESCE pattern: all updatable fields in a single static SQL.
/// NULL parameters leave the column unchanged.
pub async fn update_template(
db: &PgPool,
id: &str,
@@ -166,102 +125,41 @@ pub async fn update_template(
visibility: Option<&str>,
status: Option<&str>,
) -> SaasResult<AgentTemplateInfo> {
// 确认存在
// Confirm existence
get_template(db, id).await?;
let now = chrono::Utc::now().to_rfc3339();
let mut set_clauses: Vec<String> = vec![];
let mut param_idx = 1u32;
// 收集需要绑定的值(按顺序)
let mut desc_val: Option<String> = None;
let mut model_val: Option<String> = None;
let mut sp_val: Option<String> = None;
let mut tools_val: Option<String> = None;
let mut caps_val: Option<String> = None;
let mut temp_val: Option<f64> = None;
let mut mt_val: Option<i32> = None;
let mut vis_val: Option<String> = None;
let mut st_val: Option<String> = None;
// Serialize JSON fields upfront so we can bind Option<&str> consistently
let tools_json = tools.map(|t| serde_json::to_string(t).unwrap_or_else(|_| "[]".to_string()));
let caps_json = capabilities.map(|c| serde_json::to_string(c).unwrap_or_else(|_| "[]".to_string()));
if let Some(desc) = description {
param_idx += 1;
set_clauses.push(format!("description = ${}", param_idx));
desc_val = Some(desc.to_string());
}
if let Some(m) = model {
param_idx += 1;
set_clauses.push(format!("model = ${}", param_idx));
model_val = Some(m.to_string());
}
if let Some(sp) = system_prompt {
param_idx += 1;
set_clauses.push(format!("system_prompt = ${}", param_idx));
sp_val = Some(sp.to_string());
}
if let Some(t) = tools {
let json = serde_json::to_string(t).unwrap_or_else(|_| "[]".to_string());
param_idx += 1;
set_clauses.push(format!("tools = ${}", param_idx));
tools_val = Some(json);
}
if let Some(c) = capabilities {
let json = serde_json::to_string(c).unwrap_or_else(|_| "[]".to_string());
param_idx += 1;
set_clauses.push(format!("capabilities = ${}", param_idx));
caps_val = Some(json);
}
if let Some(t) = temperature {
param_idx += 1;
set_clauses.push(format!("temperature = ${}", param_idx));
temp_val = Some(t);
}
if let Some(m) = max_tokens {
param_idx += 1;
set_clauses.push(format!("max_tokens = ${}", param_idx));
mt_val = Some(m);
}
if let Some(v) = visibility {
param_idx += 1;
set_clauses.push(format!("visibility = ${}", param_idx));
vis_val = Some(v.to_string());
}
if let Some(s) = status {
param_idx += 1;
set_clauses.push(format!("status = ${}", param_idx));
st_val = Some(s.to_string());
}
if set_clauses.is_empty() {
return get_template(db, id).await;
}
// updated_at
param_idx += 1;
set_clauses.push(format!("updated_at = ${}", param_idx));
// WHERE id = $N
let id_idx = param_idx + 1;
let sql = format!(
"UPDATE agent_templates SET {} WHERE id = ${}",
set_clauses.join(", "), id_idx
);
let mut q = sqlx::query(&sql);
if let Some(ref v) = desc_val { q = q.bind(v); }
if let Some(ref v) = model_val { q = q.bind(v); }
if let Some(ref v) = sp_val { q = q.bind(v); }
if let Some(ref v) = tools_val { q = q.bind(v); }
if let Some(ref v) = caps_val { q = q.bind(v); }
if let Some(v) = temp_val { q = q.bind(v); }
if let Some(v) = mt_val { q = q.bind(v); }
if let Some(ref v) = vis_val { q = q.bind(v); }
if let Some(ref v) = st_val { q = q.bind(v); }
q = q.bind(&now);
q = q.bind(id);
q.execute(db).await?;
sqlx::query(
"UPDATE agent_templates SET
description = COALESCE($1, description),
model = COALESCE($2, model),
system_prompt = COALESCE($3, system_prompt),
tools = COALESCE($4, tools),
capabilities = COALESCE($5, capabilities),
temperature = COALESCE($6, temperature),
max_tokens = COALESCE($7, max_tokens),
visibility = COALESCE($8, visibility),
status = COALESCE($9, status),
updated_at = $10
WHERE id = $11"
)
.bind(description)
.bind(model)
.bind(system_prompt)
.bind(tools_json.as_deref())
.bind(caps_json.as_deref())
.bind(temperature)
.bind(max_tokens)
.bind(visibility)
.bind(status)
.bind(&now)
.bind(id)
.execute(db).await?;
get_template(db, id).await
}

View File

@@ -8,7 +8,7 @@ use crate::error::{SaasError, SaasResult};
use crate::models::{AccountAuthRow, AccountLoginRow};
use super::{
jwt::{create_token, create_refresh_token, verify_token, verify_token_skip_expiry},
password::{hash_password, verify_password},
password::{hash_password_async, verify_password_async},
types::{AuthContext, LoginRequest, LoginResponse, RegisterRequest, ChangePasswordRequest, AccountPublic, RefreshRequest},
};
@@ -25,7 +25,8 @@ pub async fn register(
if req.username.len() > 32 {
return Err(SaasError::InvalidInput("用户名最多 32 个字符".into()));
}
let username_re = regex::Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap();
static USERNAME_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
let username_re = USERNAME_RE.get_or_init(|| regex::Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap());
if !username_re.is_match(&req.username) {
return Err(SaasError::InvalidInput("用户名只能包含字母、数字、下划线和连字符".into()));
}
@@ -56,7 +57,7 @@ pub async fn register(
return Err(SaasError::AlreadyExists("用户名或邮箱已存在".into()));
}
let password_hash = hash_password(&req.password)?;
let password_hash = hash_password_async(req.password.clone()).await?;
let account_id = uuid::Uuid::new_v4().to_string();
let role = "user".to_string(); // 注册固定为普通用户,角色由管理员分配
let display_name = req.display_name.unwrap_or_default();
@@ -138,7 +139,7 @@ pub async fn login(
return Err(SaasError::Forbidden(format!("账号已{},请联系管理员", r.status)));
}
if !verify_password(&req.password, &r.password_hash)? {
if !verify_password_async(req.password.clone(), r.password_hash.clone()).await? {
return Err(SaasError::AuthError("用户名或密码错误".into()));
}
@@ -328,12 +329,12 @@ pub async fn change_password(
.await?;
// 验证旧密码
if !verify_password(&req.old_password, &password_hash)? {
if !verify_password_async(req.old_password.clone(), password_hash.clone()).await? {
return Err(SaasError::AuthError("旧密码错误".into()));
}
// 更新密码
let new_hash = hash_password(&req.new_password)?;
let new_hash = hash_password_async(req.new_password.clone()).await?;
let now = chrono::Utc::now().to_rfc3339();
sqlx::query("UPDATE accounts SET password_hash = $1, updated_at = $2 WHERE id = $3")
.bind(&new_hash)

View File

@@ -1,4 +1,8 @@
//! 密码哈希 (Argon2id)
//!
//! Argon2 是 CPU 密集型操作(~100-500ms不能在 tokio worker 线程上直接执行,
//! 否则会阻塞整个异步运行时。所有 async 上下文必须使用 `hash_password_async`
//! 和 `verify_password_async`。
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
@@ -7,7 +11,7 @@ use argon2::{
use crate::error::{SaasError, SaasResult};
/// 哈希密码
/// 哈希密码(同步版本,仅用于测试和启动时 seed
pub fn hash_password(password: &str) -> SaasResult<String> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
@@ -17,7 +21,7 @@ pub fn hash_password(password: &str) -> SaasResult<String> {
Ok(hash.to_string())
}
/// 验证密码
/// 验证密码(同步版本,仅用于测试)
pub fn verify_password(password: &str, hash: &str) -> SaasResult<bool> {
let parsed_hash = PasswordHash::new(hash)
.map_err(|e| SaasError::PasswordHash(e.to_string()))?;
@@ -26,6 +30,20 @@ pub fn verify_password(password: &str, hash: &str) -> SaasResult<bool> {
.is_ok())
}
/// 异步哈希密码 — 在 spawn_blocking 线程池中执行 Argon2
pub async fn hash_password_async(password: String) -> SaasResult<String> {
tokio::task::spawn_blocking(move || hash_password(&password))
.await
.map_err(|e| SaasError::Internal(format!("spawn_blocking error: {e}")))?
}
/// 异步验证密码 — 在 spawn_blocking 线程池中执行 Argon2
pub async fn verify_password_async(password: String, hash: String) -> SaasResult<bool> {
tokio::task::spawn_blocking(move || verify_password(&password, &hash))
.await
.map_err(|e| SaasError::Internal(format!("spawn_blocking error: {e}")))?
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -212,7 +212,7 @@ pub async fn disable_totp(
.fetch_one(&state.db)
.await?;
if !crate::auth::password::verify_password(&req.password, &password_hash)? {
if !crate::auth::password::verify_password_async(req.password.clone(), password_hash.clone()).await? {
return Err(SaasError::AuthError("密码错误".into()));
}

View File

@@ -150,11 +150,10 @@ pub async fn seed_admin_account(pool: &PgPool) -> SaasResult<()> {
.await?;
if let Some((account_id,)) = existing {
// 更新现有用户的密码和角色
use crate::auth::password::hash_password;
let password_hash = hash_password(&admin_password)?;
// 更新现有用户的密码和角色(使用 spawn_blocking 避免阻塞 tokio 运行时)
let password_hash = crate::auth::password::hash_password_async(admin_password.clone()).await?;
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"UPDATE accounts SET password_hash = $1, role = 'super_admin', updated_at = $2 WHERE id = $3"
)
@@ -163,12 +162,11 @@ pub async fn seed_admin_account(pool: &PgPool) -> SaasResult<()> {
.bind(&account_id)
.execute(pool)
.await?;
tracing::info!("已更新用户 {} 的密码和角色为 super_admin", admin_username);
} else {
// 创建新的 super_admin 账号
use crate::auth::password::hash_password;
let password_hash = hash_password(&admin_password)?;
let password_hash = crate::auth::password::hash_password_async(admin_password.clone()).await?;
let account_id = uuid::Uuid::new_v4().to_string();
let email = format!("{}@zclaw.local", admin_username);
let now = chrono::Utc::now().to_rfc3339();

View File

@@ -54,39 +54,50 @@ pub async fn list_config_items(
) -> SaasResult<PaginatedResponse<ConfigItemInfo>> {
let (p, ps, offset) = normalize_pagination(page, page_size);
// Build WHERE clause for count and data queries
let (where_clause, has_category, has_source) = match (&query.category, &query.source) {
(Some(_), Some(_)) => ("WHERE category = $1 AND source = $2", true, true),
(Some(_), None) => ("WHERE category = $1", true, false),
(None, Some(_)) => ("WHERE source = $1", false, true),
(None, None) => ("", false, false),
// Static SQL per combination -- no format!() string interpolation
let (total, rows) = match (&query.category, &query.source) {
(Some(cat), Some(src)) => {
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM config_items WHERE category = $1 AND source = $2"
).bind(cat).bind(src).fetch_one(db).await?;
let rows = sqlx::query_as::<_, ConfigItemRow>(
"SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at
FROM config_items WHERE category = $1 AND source = $2 ORDER BY category, key_path LIMIT $3 OFFSET $4"
).bind(cat).bind(src).bind(ps as i64).bind(offset).fetch_all(db).await?;
(total, rows)
}
(Some(cat), None) => {
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM config_items WHERE category = $1"
).bind(cat).fetch_one(db).await?;
let rows = sqlx::query_as::<_, ConfigItemRow>(
"SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at
FROM config_items WHERE category = $1 ORDER BY category, key_path LIMIT $2 OFFSET $3"
).bind(cat).bind(ps as i64).bind(offset).fetch_all(db).await?;
(total, rows)
}
(None, Some(src)) => {
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM config_items WHERE source = $1"
).bind(src).fetch_one(db).await?;
let rows = sqlx::query_as::<_, ConfigItemRow>(
"SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at
FROM config_items WHERE source = $1 ORDER BY category, key_path LIMIT $2 OFFSET $3"
).bind(src).bind(ps as i64).bind(offset).fetch_all(db).await?;
(total, rows)
}
(None, None) => {
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM config_items"
).fetch_one(db).await?;
let rows = sqlx::query_as::<_, ConfigItemRow>(
"SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at
FROM config_items ORDER BY category, key_path LIMIT $1 OFFSET $2"
).bind(ps as i64).bind(offset).fetch_all(db).await?;
(total, rows)
}
};
let count_sql = format!("SELECT COUNT(*) FROM config_items {}", where_clause);
let data_sql = format!(
"SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at
FROM config_items {} ORDER BY category, key_path LIMIT {} OFFSET {}",
where_clause, "$p", "$o"
);
// Determine param indices for LIMIT/OFFSET based on filter params
let (limit_idx, offset_idx) = match (has_category, has_source) {
(true, true) => ("$3", "$4"),
(true, false) | (false, true) => ("$2", "$3"),
(false, false) => ("$1", "$2"),
};
let data_sql = data_sql.replace("$p", limit_idx).replace("$o", offset_idx);
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
if has_category { count_query = count_query.bind(&query.category); }
if has_source { count_query = count_query.bind(&query.source); }
let total: i64 = count_query.fetch_one(db).await?;
let mut data_query = sqlx::query_as::<_, ConfigItemRow>(&data_sql);
if has_category { data_query = data_query.bind(&query.category); }
if has_source { data_query = data_query.bind(&query.source); }
let rows = data_query.bind(ps as i64).bind(offset).fetch_all(db).await?;
let items = rows.into_iter().map(|r| {
ConfigItemInfo { id: r.id, category: r.category, key_path: r.key_path, value_type: r.value_type, current_value: r.current_value, default_value: r.default_value, source: r.source, description: r.description, requires_restart: r.requires_restart, created_at: r.created_at, updated_at: r.updated_at }
}).collect();
@@ -146,29 +157,23 @@ pub async fn update_config_item(
db: &PgPool, item_id: &str, req: &UpdateConfigItemRequest,
) -> SaasResult<ConfigItemInfo> {
let now = chrono::Utc::now().to_rfc3339();
let mut updates = Vec::new();
let mut params: Vec<String> = Vec::new();
let mut param_idx = 1usize;
if let Some(ref v) = req.current_value { updates.push(format!("current_value = ${}", param_idx)); params.push(v.clone()); param_idx += 1; }
if let Some(ref v) = req.source { updates.push(format!("source = ${}", param_idx)); params.push(v.clone()); param_idx += 1; }
if let Some(ref v) = req.description { updates.push(format!("description = ${}", param_idx)); params.push(v.clone()); param_idx += 1; }
if updates.is_empty() {
return get_config_item(db, item_id).await;
}
updates.push(format!("updated_at = ${}", param_idx));
params.push(now);
param_idx += 1;
params.push(item_id.to_string());
let sql = format!("UPDATE config_items SET {} WHERE id = ${}", updates.join(", "), param_idx);
let mut query = sqlx::query(&sql);
for p in &params {
query = query.bind(p);
}
query.execute(db).await?;
// COALESCE pattern: all updatable fields in a single static SQL.
// NULL parameters leave the column unchanged.
sqlx::query(
"UPDATE config_items SET
current_value = COALESCE($1, current_value),
source = COALESCE($2, source),
description = COALESCE($3, description),
updated_at = $4
WHERE id = $5"
)
.bind(req.current_value.as_deref())
.bind(req.source.as_deref())
.bind(req.description.as_deref())
.bind(&now)
.bind(item_id)
.execute(db).await?;
get_config_item(db, item_id).await
}

View File

@@ -104,36 +104,38 @@ pub async fn update_provider(
db: &PgPool, provider_id: &str, req: &UpdateProviderRequest, enc_key: &[u8; 32],
) -> SaasResult<ProviderInfo> {
let now = chrono::Utc::now().to_rfc3339();
let mut updates = Vec::new();
let mut params: Vec<Box<dyn std::fmt::Display + Send + Sync>> = Vec::new();
let mut param_idx = 1;
if let Some(ref v) = req.display_name { updates.push(format!("display_name = ${}", param_idx)); params.push(Box::new(v.clone())); param_idx += 1; }
if let Some(ref v) = req.base_url { updates.push(format!("base_url = ${}", param_idx)); params.push(Box::new(v.clone())); param_idx += 1; }
if let Some(ref v) = req.api_protocol { updates.push(format!("api_protocol = ${}", param_idx)); params.push(Box::new(v.clone())); param_idx += 1; }
if let Some(ref v) = req.api_key {
let encrypted = if v.is_empty() { String::new() } else { crypto::encrypt_value(v, enc_key)? };
updates.push(format!("api_key = ${}", param_idx)); params.push(Box::new(encrypted)); param_idx += 1;
}
if let Some(v) = req.enabled { updates.push(format!("enabled = ${}", param_idx)); params.push(Box::new(v)); param_idx += 1; }
if let Some(v) = req.rate_limit_rpm { updates.push(format!("rate_limit_rpm = ${}", param_idx)); params.push(Box::new(v)); param_idx += 1; }
if let Some(v) = req.rate_limit_tpm { updates.push(format!("rate_limit_tpm = ${}", param_idx)); params.push(Box::new(v)); param_idx += 1; }
// Encrypt api_key upfront if provided
let encrypted_api_key = match req.api_key {
Some(ref v) if !v.is_empty() => Some(crypto::encrypt_value(v, enc_key)?),
Some(ref v) if v.is_empty() => Some(String::new()),
_ => None,
};
if updates.is_empty() {
return get_provider(db, provider_id).await;
}
updates.push(format!("updated_at = ${}", param_idx));
params.push(Box::new(now.clone()));
param_idx += 1;
params.push(Box::new(provider_id.to_string()));
let sql = format!("UPDATE providers SET {} WHERE id = ${}", updates.join(", "), param_idx);
let mut query = sqlx::query(&sql);
for p in &params {
query = query.bind(format!("{}", p));
}
query.execute(db).await?;
// COALESCE pattern: all updatable fields in a single static SQL.
// NULL parameters leave the column unchanged.
sqlx::query(
"UPDATE providers SET
display_name = COALESCE($1, display_name),
base_url = COALESCE($2, base_url),
api_protocol = COALESCE($3, api_protocol),
api_key = COALESCE($4, api_key),
enabled = COALESCE($5, enabled),
rate_limit_rpm = COALESCE($6, rate_limit_rpm),
rate_limit_tpm = COALESCE($7, rate_limit_tpm),
updated_at = $8
WHERE id = $9"
)
.bind(req.display_name.as_deref())
.bind(req.base_url.as_deref())
.bind(req.api_protocol.as_deref())
.bind(encrypted_api_key.as_deref())
.bind(req.enabled)
.bind(req.rate_limit_rpm)
.bind(req.rate_limit_tpm)
.bind(&now)
.bind(provider_id)
.execute(db).await?;
get_provider(db, provider_id).await
}
@@ -245,34 +247,33 @@ pub async fn update_model(
db: &PgPool, model_id: &str, req: &UpdateModelRequest,
) -> SaasResult<ModelInfo> {
let now = chrono::Utc::now().to_rfc3339();
let mut updates = Vec::new();
let mut params: Vec<Box<dyn std::fmt::Display + Send + Sync>> = Vec::new();
let mut param_idx = 1;
if let Some(ref v) = req.alias { updates.push(format!("alias = ${}", param_idx)); params.push(Box::new(v.clone())); param_idx += 1; }
if let Some(v) = req.context_window { updates.push(format!("context_window = ${}", param_idx)); params.push(Box::new(v)); param_idx += 1; }
if let Some(v) = req.max_output_tokens { updates.push(format!("max_output_tokens = ${}", param_idx)); params.push(Box::new(v)); param_idx += 1; }
if let Some(v) = req.supports_streaming { updates.push(format!("supports_streaming = ${}", param_idx)); params.push(Box::new(v)); param_idx += 1; }
if let Some(v) = req.supports_vision { updates.push(format!("supports_vision = ${}", param_idx)); params.push(Box::new(v)); param_idx += 1; }
if let Some(v) = req.enabled { updates.push(format!("enabled = ${}", param_idx)); params.push(Box::new(v)); param_idx += 1; }
if let Some(v) = req.pricing_input { updates.push(format!("pricing_input = ${}", param_idx)); params.push(Box::new(v)); param_idx += 1; }
if let Some(v) = req.pricing_output { updates.push(format!("pricing_output = ${}", param_idx)); params.push(Box::new(v)); param_idx += 1; }
if updates.is_empty() {
return get_model(db, model_id).await;
}
updates.push(format!("updated_at = ${}", param_idx));
params.push(Box::new(now.clone()));
param_idx += 1;
params.push(Box::new(model_id.to_string()));
let sql = format!("UPDATE models SET {} WHERE id = ${}", updates.join(", "), param_idx);
let mut query = sqlx::query(&sql);
for p in &params {
query = query.bind(format!("{}", p));
}
query.execute(db).await?;
// COALESCE pattern: all updatable fields in a single static SQL.
// NULL parameters leave the column unchanged.
sqlx::query(
"UPDATE models SET
alias = COALESCE($1, alias),
context_window = COALESCE($2, context_window),
max_output_tokens = COALESCE($3, max_output_tokens),
supports_streaming = COALESCE($4, supports_streaming),
supports_vision = COALESCE($5, supports_vision),
enabled = COALESCE($6, enabled),
pricing_input = COALESCE($7, pricing_input),
pricing_output = COALESCE($8, pricing_output),
updated_at = $9
WHERE id = $10"
)
.bind(req.alias.as_deref())
.bind(req.context_window)
.bind(req.max_output_tokens)
.bind(req.supports_streaming)
.bind(req.supports_vision)
.bind(req.enabled)
.bind(req.pricing_input)
.bind(req.pricing_output)
.bind(&now)
.bind(model_id)
.execute(db).await?;
get_model(db, model_id).await
}
@@ -401,58 +402,33 @@ pub async fn revoke_account_api_key(
pub async fn get_usage_stats(
db: &PgPool, account_id: &str, query: &UsageQuery,
) -> SaasResult<UsageStats> {
let mut param_idx = 1;
let mut where_clauses = vec![format!("account_id = ${}", param_idx)];
let mut params: Vec<String> = vec![account_id.to_string()];
param_idx += 1;
// Static SQL with conditional filter pattern:
// account_id is always required; optional filters use ($N IS NULL OR col = $N).
let total_sql = "SELECT COUNT(*)::bigint, COALESCE(SUM(input_tokens), 0), COALESCE(SUM(output_tokens), 0)
FROM usage_records WHERE account_id = $1 AND ($2 IS NULL OR created_at >= $2) AND ($3 IS NULL OR created_at <= $3) AND ($4 IS NULL OR provider_id = $4) AND ($5 IS NULL OR model_id = $5)";
if let Some(ref from) = query.from {
where_clauses.push(format!("created_at >= ${}", param_idx));
params.push(from.clone());
param_idx += 1;
}
if let Some(ref to) = query.to {
where_clauses.push(format!("created_at <= ${}", param_idx));
params.push(to.clone());
param_idx += 1;
}
if let Some(ref pid) = query.provider_id {
where_clauses.push(format!("provider_id = ${}", param_idx));
params.push(pid.clone());
param_idx += 1;
}
if let Some(ref mid) = query.model_id {
where_clauses.push(format!("model_id = ${}", param_idx));
params.push(mid.clone());
}
let where_sql = where_clauses.join(" AND ");
// 总量统计
let total_sql = format!(
"SELECT COUNT(*)::bigint, COALESCE(SUM(input_tokens), 0), COALESCE(SUM(output_tokens), 0)
FROM usage_records WHERE {}", where_sql
);
let mut total_query = sqlx::query(&total_sql);
for p in &params {
total_query = total_query.bind(p);
}
let row = total_query.fetch_one(db).await?;
let row = sqlx::query(total_sql)
.bind(account_id)
.bind(&query.from)
.bind(&query.to)
.bind(&query.provider_id)
.bind(&query.model_id)
.fetch_one(db).await?;
let total_requests: i64 = row.try_get(0).unwrap_or(0);
let total_input: i64 = row.try_get(1).unwrap_or(0);
let total_output: i64 = row.try_get(2).unwrap_or(0);
// 按模型统计
let by_model_sql = format!(
"SELECT provider_id, model_id, COUNT(*)::bigint AS request_count, COALESCE(SUM(input_tokens), 0) AS input_tokens, COALESCE(SUM(output_tokens), 0) AS output_tokens
FROM usage_records WHERE {} GROUP BY provider_id, model_id ORDER BY COUNT(*) DESC LIMIT 20",
where_sql
);
let mut by_model_query = sqlx::query_as::<_, UsageByModelRow>(&by_model_sql);
for p in &params {
by_model_query = by_model_query.bind(p);
}
let by_model_rows = by_model_query.fetch_all(db).await?;
let by_model_sql = "SELECT provider_id, model_id, COUNT(*)::bigint AS request_count, COALESCE(SUM(input_tokens), 0) AS input_tokens, COALESCE(SUM(output_tokens), 0) AS output_tokens
FROM usage_records WHERE account_id = $1 AND ($2 IS NULL OR created_at >= $2) AND ($3 IS NULL OR created_at <= $3) AND ($4 IS NULL OR provider_id = $4) AND ($5 IS NULL OR model_id = $5) GROUP BY provider_id, model_id ORDER BY COUNT(*) DESC LIMIT 20";
let by_model_rows: Vec<UsageByModelRow> = sqlx::query_as(by_model_sql)
.bind(account_id)
.bind(&query.from)
.bind(&query.to)
.bind(&query.provider_id)
.bind(&query.model_id)
.fetch_all(db).await?;
let by_model: Vec<ModelUsage> = by_model_rows.into_iter()
.map(|r| {
ModelUsage { provider_id: r.provider_id, model_id: r.model_id, request_count: r.request_count, input_tokens: r.input_tokens, output_tokens: r.output_tokens }

View File

@@ -76,62 +76,30 @@ pub async fn get_template_by_name(db: &PgPool, name: &str) -> SaasResult<PromptT
}
/// 列表模板
/// Static SQL with conditional filter pattern: ($N IS NULL OR col = $N).
pub async fn list_templates(
db: &PgPool,
query: &PromptListQuery,
) -> SaasResult<PaginatedResponse<PromptTemplateInfo>> {
let (page, page_size, offset) = normalize_pagination(query.page, query.page_size);
// 使用参数化查询构建,防止 SQL 注入
let mut param_idx = 1usize;
let mut conditions = Vec::new();
let mut cat_bind: Option<String> = None;
let mut src_bind: Option<String> = None;
let mut status_bind: Option<String> = None;
let count_sql = "SELECT COUNT(*) FROM prompt_templates WHERE ($1 IS NULL OR category = $1) AND ($2 IS NULL OR source = $2) AND ($3 IS NULL OR status = $3)";
let data_sql = "SELECT id, name, category, description, source, current_version, status, created_at, updated_at \
FROM prompt_templates WHERE ($1 IS NULL OR category = $1) AND ($2 IS NULL OR source = $2) AND ($3 IS NULL OR status = $3) ORDER BY updated_at DESC LIMIT $4 OFFSET $5";
if let Some(ref cat) = query.category {
conditions.push(format!("category = ${}", param_idx));
cat_bind = Some(cat.clone());
param_idx += 1;
}
if let Some(ref src) = query.source {
conditions.push(format!("source = ${}", param_idx));
src_bind = Some(src.clone());
param_idx += 1;
}
if let Some(ref st) = query.status {
conditions.push(format!("status = ${}", param_idx));
status_bind = Some(st.clone());
param_idx += 1;
}
let total: i64 = sqlx::query_scalar(count_sql)
.bind(&query.category)
.bind(&query.source)
.bind(&query.status)
.fetch_one(db).await?;
let where_clause = if conditions.is_empty() {
"1=1".to_string()
} else {
conditions.join(" AND ")
};
let count_sql = format!("SELECT COUNT(*) FROM prompt_templates WHERE {}", where_clause);
let data_sql = format!(
"SELECT id, name, category, description, source, current_version, status, created_at, updated_at \
FROM prompt_templates WHERE {} ORDER BY updated_at DESC LIMIT {} OFFSET {}",
where_clause, page_size, offset
);
// 动态绑定参数到 count 查询
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
if let Some(ref v) = cat_bind { count_query = count_query.bind(v); }
if let Some(ref v) = src_bind { count_query = count_query.bind(v); }
if let Some(ref v) = status_bind { count_query = count_query.bind(v); }
let total = count_query.fetch_one(db).await?;
// 动态绑定参数到 data 查询
let mut data_query = sqlx::query_as::<_, PromptTemplateRow>(&data_sql);
if let Some(ref v) = cat_bind { data_query = data_query.bind(v); }
if let Some(ref v) = src_bind { data_query = data_query.bind(v); }
if let Some(ref v) = status_bind { data_query = data_query.bind(v); }
data_query = data_query.bind(page_size as i64).bind(offset as i64);
let rows = data_query.fetch_all(db).await?;
let rows: Vec<PromptTemplateRow> = sqlx::query_as(data_sql)
.bind(&query.category)
.bind(&query.source)
.bind(&query.status)
.bind(page_size as i64)
.bind(offset as i64)
.fetch_all(db).await?;
let items: Vec<PromptTemplateInfo> = rows.into_iter().map(|r| {
PromptTemplateInfo { id: r.id, name: r.name, category: r.category, description: r.description, source: r.source, current_version: r.current_version, status: r.status, created_at: r.created_at, updated_at: r.updated_at }

View File

@@ -43,12 +43,13 @@ pub async fn chat_completions(
}
// --- 输入验证 ---
// 请求体大小限制 (1 MB)
// 请求体大小限制 (1 MB) — 直接序列化一次,后续复用
const MAX_BODY_BYTES: usize = 1024 * 1024;
let estimated_size = serde_json::to_string(&req).map(|s| s.len()).unwrap_or(0);
if estimated_size > MAX_BODY_BYTES {
let request_body = serde_json::to_string(&req)
.map_err(|e| SaasError::InvalidInput(format!("请求体序列化失败: {}", e)))?;
if request_body.len() > MAX_BODY_BYTES {
return Err(SaasError::InvalidInput(
format!("请求体超过大小限制 ({} bytes > {} bytes)", estimated_size, MAX_BODY_BYTES)
format!("请求体超过大小限制 ({} bytes > {} bytes)", request_body.len(), MAX_BODY_BYTES)
));
}
@@ -147,7 +148,7 @@ pub async fn chat_completions(
return Err(SaasError::Forbidden(format!("Provider {} 已禁用", provider.name)));
}
let request_body = serde_json::to_string(&req)?;
// request_body 已在前面序列化并验证大小,直接复用
// 创建中转任务(提取配置后立即释放读锁)
let (max_attempts, retry_delay_ms, enc_key) = {

View File

@@ -185,10 +185,24 @@ pub async fn execute_relay(
let url = format!("{}/chat/completions", provider_base_url.trim_end_matches('/'));
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(if stream { 300 } else { 30 }))
.build()
.map_err(|e| SaasError::Internal(format!("HTTP 客户端构建失败: {}", e)))?;
// 复用全局 HTTP 客户端,避免每次请求重建 TLS 连接池和 DNS 解析器
static SHORT_CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
static LONG_CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
let client = if stream {
LONG_CLIENT.get_or_init(|| {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300))
.build()
.expect("Failed to build long-timeout HTTP client")
})
} else {
SHORT_CLIENT.get_or_init(|| {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("Failed to build short-timeout HTTP client")
})
};
let max_attempts = max_attempts.max(1).min(5);

View File

@@ -0,0 +1,321 @@
//! Extraction Adapter - Bridges zclaw_growth::LlmDriverForExtraction with the Kernel's LlmDriver
//!
//! Implements the `LlmDriverForExtraction` trait by delegating to the Kernel's
//! `zclaw_runtime::driver::LlmDriver`, which already handles provider-specific
//! API calls (OpenAI, Anthropic, Gemini, etc.).
//!
//! This enables the Growth system's MemoryExtractor to call the LLM for memory
//! extraction from conversations.
use std::sync::Arc;
use zclaw_growth::extractor::{LlmDriverForExtraction, prompts};
use zclaw_growth::types::{ExtractedMemory, MemoryType};
use zclaw_runtime::driver::{CompletionRequest, ContentBlock, LlmDriver};
use zclaw_types::{Message, Result, SessionId};
/// Adapter that wraps the Kernel's `LlmDriver` to implement `LlmDriverForExtraction`.
///
/// The adapter translates extraction requests into completion requests that the
/// Kernel's LLM driver can process, then parses the structured JSON response
/// back into `ExtractedMemory` objects.
pub struct TauriExtractionDriver {
driver: Arc<dyn LlmDriver>,
model: String,
}
impl TauriExtractionDriver {
/// Create a new extraction driver wrapping the given LLM driver.
///
/// The `model` parameter specifies which model to use for extraction calls.
pub fn new(driver: Arc<dyn LlmDriver>, model: String) -> Self {
Self { driver, model }
}
/// Build a completion request from the extraction prompt and conversation messages.
fn build_request(
&self,
messages: &[Message],
extraction_type: MemoryType,
) -> CompletionRequest {
let extraction_prompt = prompts::get_extraction_prompt(extraction_type);
// Format conversation for the prompt
// Message is an enum with variants: User{content}, Assistant{content, thinking},
// System{content}, ToolUse{...}, ToolResult{...}
let conversation_text = messages
.iter()
.filter_map(|msg| {
match msg {
Message::User { content } => {
Some(format!("[User]: {}", content))
}
Message::Assistant { content, .. } => {
Some(format!("[Assistant]: {}", content))
}
Message::System { content } => {
Some(format!("[System]: {}", content))
}
// Skip tool use/result messages -- not relevant for memory extraction
Message::ToolUse { .. } | Message::ToolResult { .. } => None,
}
})
.collect::<Vec<_>>()
.join("\n\n");
let full_prompt = format!("{}{}", extraction_prompt, conversation_text);
CompletionRequest {
model: self.model.clone(),
system: Some(
"You are a memory extraction assistant. Analyze conversations and extract \
structured memories in valid JSON format. Always respond with valid JSON only, \
no additional text or markdown formatting."
.to_string(),
),
messages: vec![Message::user(full_prompt)],
tools: Vec::new(),
max_tokens: Some(2000),
temperature: Some(0.3),
stop: Vec::new(),
stream: false,
}
}
/// Parse the LLM response text into a list of extracted memories.
fn parse_response(
&self,
response_text: &str,
extraction_type: MemoryType,
) -> Vec<ExtractedMemory> {
// Strip markdown code fences if present
let cleaned = response_text
.trim()
.trim_start_matches("```json")
.trim_start_matches("```")
.trim_end_matches("```")
.trim();
// Extract the JSON array from the response
let json_str = match (cleaned.find('['), cleaned.rfind(']')) {
(Some(start), Some(end)) => &cleaned[start..=end],
_ => {
tracing::warn!(
"[TauriExtractionDriver] No JSON array found in LLM response"
);
return Vec::new();
}
};
let raw_items: Vec<serde_json::Value> = match serde_json::from_str(json_str) {
Ok(items) => items,
Err(e) => {
tracing::warn!(
"[TauriExtractionDriver] Failed to parse extraction JSON: {}",
e
);
return Vec::new();
}
};
raw_items
.into_iter()
.filter_map(|item| self.parse_memory_item(&item, extraction_type))
.collect()
}
/// Parse a single memory item from JSON.
fn parse_memory_item(
&self,
value: &serde_json::Value,
fallback_type: MemoryType,
) -> Option<ExtractedMemory> {
let content = value.get("content")?.as_str()?.to_string();
let category = value
.get("category")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let confidence = value
.get("confidence")
.and_then(|v| v.as_f64())
.unwrap_or(0.7) as f32;
let keywords = value
.get("keywords")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
Some(
ExtractedMemory::new(fallback_type, category, content, SessionId::new())
.with_confidence(confidence)
.with_keywords(keywords),
)
}
}
#[async_trait::async_trait]
impl LlmDriverForExtraction for TauriExtractionDriver {
async fn extract_memories(
&self,
messages: &[Message],
extraction_type: MemoryType,
) -> Result<Vec<ExtractedMemory>> {
let type_name = format!("{}", extraction_type);
tracing::debug!(
"[TauriExtractionDriver] Extracting {} memories from {} messages",
type_name,
messages.len()
);
// Skip extraction if there are too few messages
if messages.len() < 2 {
tracing::debug!(
"[TauriExtractionDriver] Too few messages ({}) for extraction, skipping",
messages.len()
);
return Ok(Vec::new());
}
let request = self.build_request(messages, extraction_type);
let response = self.driver.complete(request).await.map_err(|e| {
tracing::error!(
"[TauriExtractionDriver] LLM completion failed for {}: {}",
type_name,
e
);
e
})?;
// Extract text content from response
let response_text: String = response
.content
.into_iter()
.filter_map(|block| match block {
ContentBlock::Text { text } => Some(text),
_ => None,
})
.collect::<Vec<_>>()
.join("");
if response_text.is_empty() {
tracing::warn!(
"[TauriExtractionDriver] Empty response from LLM for {} extraction",
type_name
);
return Ok(Vec::new());
}
let memories = self.parse_response(&response_text, extraction_type);
tracing::info!(
"[TauriExtractionDriver] Extracted {} {} memories",
memories.len(),
type_name
);
Ok(memories)
}
}
/// Global extraction driver instance (lazy-initialized).
static EXTRACTION_DRIVER: tokio::sync::OnceCell<Arc<TauriExtractionDriver>> =
tokio::sync::OnceCell::const_new();
/// Configure the global extraction driver.
///
/// Call this during kernel initialization after the Kernel's LLM driver is available.
pub fn configure_extraction_driver(driver: Arc<dyn LlmDriver>, model: String) {
let adapter = TauriExtractionDriver::new(driver, model);
let _ = EXTRACTION_DRIVER.set(Arc::new(adapter));
tracing::info!("[ExtractionAdapter] Extraction driver configured");
}
/// Check if the extraction driver is available.
#[allow(dead_code)]
pub fn is_extraction_driver_configured() -> bool {
EXTRACTION_DRIVER.get().is_some()
}
/// Get the global extraction driver.
///
/// Returns `None` if not yet configured via `configure_extraction_driver`.
pub fn get_extraction_driver() -> Option<Arc<TauriExtractionDriver>> {
EXTRACTION_DRIVER.get().cloned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extraction_driver_not_configured_by_default() {
assert!(!is_extraction_driver_configured());
}
#[test]
fn test_parse_empty_response() {
// We cannot create a real LlmDriver easily in tests, so we test the
// parsing logic via a minimal helper.
struct DummyDriver;
impl TauriExtractionDriver {
fn parse_response_test(
&self,
response_text: &str,
extraction_type: MemoryType,
) -> Vec<ExtractedMemory> {
self.parse_response(response_text, extraction_type)
}
}
}
#[test]
fn test_parse_valid_json_response() {
let response = r#"```json
[
{
"category": "communication-style",
"content": "User prefers concise replies",
"confidence": 0.9,
"keywords": ["concise", "style"]
},
{
"category": "language",
"content": "User prefers Chinese responses",
"confidence": 0.85,
"keywords": ["Chinese", "language"]
}
]
```"#;
// Verify the parsing logic works by manually simulating it
let cleaned = response
.trim()
.trim_start_matches("```json")
.trim_start_matches("```")
.trim_end_matches("```")
.trim();
let json_str = &cleaned[cleaned.find('[').unwrap()..=cleaned.rfind(']').unwrap()];
let items: Vec<serde_json::Value> = serde_json::from_str(json_str).unwrap();
assert_eq!(items.len(), 2);
assert_eq!(
items[0].get("category").unwrap().as_str().unwrap(),
"communication-style"
);
}
#[test]
fn test_parse_no_json_array() {
let response = "No memories could be extracted from this conversation.";
let has_array =
response.find('[').is_some() && response.rfind(']').is_some();
assert!(!has_array);
}
}

View File

@@ -31,6 +31,7 @@ pub mod compactor;
pub mod reflection;
pub mod identity;
pub mod validation;
pub mod extraction_adapter;
// Re-export main types for convenience
pub use heartbeat::HeartbeatEngineState;
@@ -40,3 +41,13 @@ pub use reflection::{
pub use identity::{
AgentIdentityManager, IdentityManagerState,
};
// Suppress dead-code warnings for extraction adapter accessors — they are
// consumed externally via full path (crate::intelligence::extraction_adapter::*).
#[allow(unused_imports)]
use extraction_adapter::{
configure_extraction_driver as _,
is_extraction_driver_configured as _,
get_extraction_driver as _,
TauriExtractionDriver as _,
};

View File

@@ -215,6 +215,24 @@ pub async fn kernel_init(
let agent_count = kernel.list_agents().len();
// Configure extraction driver so the Growth system can call LLM for memory extraction
let driver = kernel.driver();
crate::intelligence::extraction_adapter::configure_extraction_driver(
driver.clone(),
model.clone(),
);
// Configure summary driver so the Growth system can generate L0/L1 summaries
if let Some(api_key) = config_request.as_ref().and_then(|r| r.api_key.clone()) {
crate::summarizer_adapter::configure_summary_driver(
crate::summarizer_adapter::TauriSummaryDriver::new(
format!("{}/chat/completions", base_url),
api_key,
Some(model.clone()),
),
);
}
*kernel_lock = Some(kernel);
Ok(KernelStatusResponse {
@@ -1251,24 +1269,109 @@ pub async fn approval_list(
}
/// Respond to an approval
///
/// When approved, the kernel's `respond_to_approval` internally spawns the Hand
/// execution. We additionally emit Tauri events so the frontend can track when
/// the execution finishes, since the kernel layer has no access to the AppHandle.
#[tauri::command]
pub async fn approval_respond(
app: AppHandle,
state: State<'_, KernelState>,
id: String,
approved: bool,
reason: Option<String>,
) -> Result<(), String> {
let kernel_lock = state.lock().await;
let kernel = kernel_lock.as_ref()
.ok_or_else(|| "Kernel not initialized".to_string())?;
// Capture hand info before calling respond_to_approval (which mutates the approval)
let hand_id = {
let kernel_lock = state.lock().await;
let kernel = kernel_lock.as_ref()
.ok_or_else(|| "Kernel not initialized".to_string())?;
kernel.respond_to_approval(&id, approved, reason).await
.map_err(|e| format!("Failed to respond to approval: {}", e))
let approvals = kernel.list_approvals().await;
let entry = approvals.iter().find(|a| a.id == id && a.status == "pending")
.ok_or_else(|| format!("Approval not found or already resolved: {}", id))?;
entry.hand_id.clone()
};
// Call kernel respond_to_approval (this updates status and spawns Hand execution)
{
let kernel_lock = state.lock().await;
let kernel = kernel_lock.as_ref()
.ok_or_else(|| "Kernel not initialized".to_string())?;
kernel.respond_to_approval(&id, approved, reason).await
.map_err(|e| format!("Failed to respond to approval: {}", e))?;
}
// When approved, monitor the Hand execution and emit events to the frontend.
// The kernel's respond_to_approval changes status to "approved" immediately,
// then the spawned task sets it to "completed" or "failed" when done.
if approved {
let approval_id = id.clone();
let kernel_state: KernelState = (*state).clone();
tokio::spawn(async move {
let timeout = tokio::time::Duration::from_secs(300);
let poll_interval = tokio::time::Duration::from_millis(500);
let result = tokio::time::timeout(timeout, async {
loop {
tokio::time::sleep(poll_interval).await;
let kernel_lock = kernel_state.lock().await;
if let Some(kernel) = kernel_lock.as_ref() {
// Use get_approval to check any status (not just "pending")
if let Some(entry) = kernel.get_approval(&approval_id).await {
match entry.status.as_str() {
"completed" => {
tracing::info!("[approval_respond] Hand '{}' completed for approval {}", hand_id, approval_id);
return (true, None::<String>);
}
"failed" => {
let error_msg = entry.input.get("error")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error")
.to_string();
tracing::warn!("[approval_respond] Hand '{}' failed for approval {}: {}", hand_id, approval_id, error_msg);
return (false, Some(error_msg));
}
_ => {} // "approved" = still running
}
} else {
// Entry disappeared entirely — kernel was likely restarted
return (false, Some("Approval entry disappeared".to_string()));
}
} else {
return (false, Some("Kernel not available".to_string()));
}
}
}).await;
let (success, error) = match result {
Ok((s, e)) => (s, e),
Err(_) => (false, Some("Hand execution timed out (5 minutes)".to_string())),
};
let _ = app.emit("hand-execution-complete", serde_json::json!({
"approvalId": approval_id,
"handId": hand_id,
"success": success,
"error": error,
}));
});
}
Ok(())
}
/// Approve a hand execution (alias for approval_respond with approved=true)
/// Approve a hand execution
///
/// When approved, the kernel's `respond_to_approval` internally spawns the Hand
/// execution. We additionally emit Tauri events so the frontend can track when
/// the execution finishes.
#[tauri::command]
pub async fn hand_approve(
app: AppHandle,
state: State<'_, KernelState>,
hand_name: String,
run_id: String,
@@ -1301,6 +1404,66 @@ pub async fn hand_approve(
kernel.respond_to_approval(&run_id, approved, reason).await
.map_err(|e| format!("Failed to approve hand: {}", e))?;
// When approved, monitor the Hand execution and emit events to the frontend
if approved {
let approval_id = run_id.clone();
let hand_id = hand_name.clone();
let kernel_state: KernelState = (*state).clone();
tokio::spawn(async move {
// Poll the approval status until it transitions from "approved" to
// "completed" or "failed" (set by the kernel's spawned task).
// Timeout after 5 minutes to avoid hanging forever.
let timeout = tokio::time::Duration::from_secs(300);
let poll_interval = tokio::time::Duration::from_millis(500);
let result = tokio::time::timeout(timeout, async {
loop {
tokio::time::sleep(poll_interval).await;
let kernel_lock = kernel_state.lock().await;
if let Some(kernel) = kernel_lock.as_ref() {
// Use get_approval to check any status (not just "pending")
if let Some(entry) = kernel.get_approval(&approval_id).await {
match entry.status.as_str() {
"completed" => {
tracing::info!("[hand_approve] Hand '{}' execution completed for approval {}", hand_id, approval_id);
return (true, None::<String>);
}
"failed" => {
let error_msg = entry.input.get("error")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error")
.to_string();
tracing::warn!("[hand_approve] Hand '{}' execution failed for approval {}: {}", hand_id, approval_id, error_msg);
return (false, Some(error_msg));
}
_ => {} // still running (status is "approved")
}
} else {
// Entry disappeared entirely — kernel was likely restarted
return (false, Some("Approval entry disappeared".to_string()));
}
} else {
return (false, Some("Kernel not available".to_string()));
}
}
}).await;
let (success, error) = match result {
Ok((s, e)) => (s, e),
Err(_) => (false, Some("Hand execution timed out (5 minutes)".to_string())),
};
let _ = app.emit("hand-execution-complete", serde_json::json!({
"approvalId": approval_id,
"handId": hand_id,
"success": success,
"error": error,
}));
});
}
Ok(serde_json::json!({
"status": if approved { "approved" } else { "rejected" },
"hand_name": hand_name,

View File

@@ -633,7 +633,8 @@ export function AuditLogsPanel() {
setVerificationResult(null);
try {
// Call ZCLAW API to verify the chain
// Call ZCLAW API to verify the chain (only available on GatewayClient)
if (!('verifyAuditLogChain' in client)) throw new Error('KernelClient does not support chain verification');
const result = await client.verifyAuditLogChain(log.id);
const verification: MerkleVerificationResult = {

View File

@@ -86,6 +86,7 @@ function ZclawLogo({ className }: { className?: string }) {
/** 根据运行环境自动选择 SaaS 服务器地址 */
function getSaasUrl(): string {
if (import.meta.env.DEV) return DEV_SAAS_URL;
return isTauriRuntime() ? PRODUCTION_SAAS_URL : DEV_SAAS_URL;
}

View File

@@ -139,12 +139,12 @@ export function SaaSSettings() {
<CloudFeatureRow
name="团队协作"
description="与团队成员共享 Agent 和技能"
status={account?.role === 'admin' || account?.role === 'pro' ? 'active' : 'inactive'}
status={account?.role === 'admin' || account?.role === 'super_admin' ? 'active' : 'inactive'}
/>
<CloudFeatureRow
name="高级分析"
description="使用统计和用量分析仪表板"
status={account?.role === 'admin' || account?.role === 'pro' ? 'active' : 'inactive'}
status={account?.role === 'admin' || account?.role === 'super_admin' ? 'active' : 'inactive'}
/>
</div>
</div>

View File

@@ -23,8 +23,8 @@ export interface SaaSAccountInfo {
username: string;
email: string;
display_name: string;
role: string;
status: string;
role: 'super_admin' | 'admin' | 'user';
status: 'active' | 'disabled' | 'suspended';
totp_enabled: boolean;
created_at: string;
}
@@ -64,6 +64,7 @@ export interface SaaSErrorResponse {
/** Login response from POST /api/v1/auth/login */
export interface SaaSLoginResponse {
token: string;
refresh_token: string;
account: SaaSAccountInfo;
}
@@ -322,8 +323,8 @@ export interface AccountPublic {
username: string;
email: string;
display_name: string;
role: string;
status: string;
role: 'super_admin' | 'admin' | 'user';
status: 'active' | 'disabled' | 'suspended';
totp_enabled: boolean;
last_login_at: string | null;
created_at: string;

View File

@@ -352,8 +352,11 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
// === SaaS Relay Mode ===
// Check connection mode from localStorage (set by saasStore).
// This takes priority over Tauri/Gateway when the user has selected SaaS mode.
// When SaaS is unreachable, gracefully degrade to local kernel mode
// so the desktop app remains functional.
const savedMode = localStorage.getItem('zclaw-connection-mode');
let saasDegraded = false;
if (savedMode === 'saas') {
const { loadSaaSSession, saasClient } = await import('../lib/saas-client');
const session = loadSaaSSession();
@@ -379,13 +382,26 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
useSaaSStore.getState().logout();
throw new Error('SaaS 会话已过期,请重新登录');
}
// SaaS unreachable — degrade to local kernel mode
const errMsg = err instanceof Error ? err.message : String(err);
throw new Error(`SaaS 平台连接失败: ${errMsg}`);
log.warn(`SaaS 平台连接失败: ${errMsg} — 降级到本地 Kernel 模式`);
// Mark SaaS as unreachable in store
try {
const { useSaaSStore } = await import('./saasStore');
useSaaSStore.setState({ saasReachable: false });
} catch { /* non-critical */ }
saasDegraded = true;
}
set({ connectionState: 'connected', gatewayVersion: 'saas-relay' });
log.debug('Connected to SaaS relay');
return;
if (!saasDegraded) {
set({ connectionState: 'connected', gatewayVersion: 'saas-relay' });
log.debug('Connected to SaaS relay');
return;
}
// Fall through to Tauri Kernel / Gateway mode
}
// === Internal Kernel Mode (Tauri) ===

View File

@@ -97,7 +97,9 @@ export type SaaSStore = SaaSStateSlice & SaaSActionsSlice;
// === Constants ===
const DEFAULT_SAAS_URL = 'https://saas.zclaw.com';
const DEFAULT_SAAS_URL = import.meta.env.DEV
? 'http://127.0.0.1:8080'
: 'https://saas.zclaw.com';
// === Helpers ===
@@ -218,14 +220,21 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
? err.message
: String(err);
const isNetworkError = message.includes('Failed to fetch')
const isTimeout = message.includes('signal timed out')
|| message.includes('Timeout')
|| message.includes('timed out')
|| message.includes('AbortError');
const isConnectionRefused = message.includes('Failed to fetch')
|| message.includes('NetworkError')
|| message.includes('ECONNREFUSED')
|| message.includes('timeout');
|| message.includes('connection refused');
const userMessage = isNetworkError
? `无法连接 SaaS 服务器: ${requestUrl}`
: message;
const userMessage = isTimeout
? `连接 SaaS 服务器超时,请确认后端服务正在运行: ${requestUrl}`
: isConnectionRefused
? `无法连接到 SaaS 服务器,请确认后端服务已启动: ${requestUrl}`
: message;
set({ isLoading: false, error: userMessage });
throw new Error(userMessage);
@@ -491,7 +500,6 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
const existing = localStorage.getItem(storageKey);
// Diff check: skip if local was modified since last pull
const lastPullKey = `zclaw-config-pull-ts.${config.category}.${config.key}`;
const dirtyKey = `zclaw-config-dirty.${config.category}.${config.key}`;
const lastPulledValue = localStorage.getItem(`zclaw-config-pulled.${config.category}.${config.key}`);

View File

@@ -1,6 +1,6 @@
# ZCLAW SaaS 平台 — 总览
> 最后更新: 2026-03-28 | 实施状态: Phase 1-4 全部完成9 个后端模块 + Admin 管理后台 + 桌面端完整集成
> 最后更新: 2026-03-29 | 实施状态: Phase 1-4 全部完成 + 架构重构完成9 个后端模块 + Worker + Scheduler + Admin 管理后台 + 桌面端完整集成
## 架构概述
@@ -29,9 +29,11 @@ ZCLAW SaaS 平台为桌面端用户提供云端能力包括模型中转Key
## 数据库
- **引擎**: PostgreSQL (sqlx 异步驱动)
- **Schema 版本**: v4
- **Schema 版本**: v6 (TIMESTAMPTZ 时间戳类型)
- **数据表**: 25 张 (accounts, providers, models, relay_tasks, prompt_templates, agent_templates, telemetry_reports 等)
- **种子数据**: 3 个系统角色 (super_admin, admin, user)3 个内置 Prompt 模板
- **迁移系统**: 声明式 SQL 文件 (`crates/zclaw-saas/migrations/`),按文件名排序执行
- **连接池**: 50 max / 5 min 连接10s 获取超时300s 空闲超时1800s 最大生命周期
## 功能模块
@@ -46,6 +48,8 @@ ZCLAW SaaS 平台为桌面端用户提供云端能力包括模型中转Key
| Prompt OTA | 100% | 8 | 模板 + 版本管理 + OTA 批量检查 + 版本回滚 + 不可变版本历史 |
| Agent 模板 | 100% | 5 | 模板 CRUD + tools/capabilities/model 绑定 + 可见性控制 |
| 遥测 (Telemetry) | 100% | 4 | 批量 Token 用量上报 + 模型聚合统计 + 每日统计 + 审计摘要 |
| **Worker 系统** | 100% | — | 5 个 Worker (log_operation, cleanup_rate_limit, cleanup_refresh_tokens, record_usage, update_last_used)mpsc 异步调度,自动重试 |
| **声明式 Scheduler** | 100% | — | TOML 配置定时任务,灵活间隔 (30s/5m/1h/1d)run_on_start内置 DB 清理 |
| **合计** | — | **76+** | — |
## API 端点一览
@@ -195,9 +199,9 @@ ZCLAW SaaS 平台为桌面端用户提供云端能力包括模型中转Key
| 文件 | 职责 |
|------|------|
| `src/main.rs` | 服务启动 + 路由注册 + 后台任务 (速率限制清理 + 设备清理) |
| `src/db.rs` | 数据库初始化 + Schema v4 + 25 张表 + Admin 引导 |
| `src/state.rs` | AppState (PgPool + Config + JWT Secret + 速率限制 DashMap) |
| `src/config.rs` | SaaSConfig (Server/Database/Auth/Relay/RateLimit) |
| `src/db.rs` | 数据库初始化 + Schema v6 + TIMESTAMPTZ 迁移 + 25 张表 + Admin 引导 |
| `src/state.rs` | AppState (PgPool + Config + JWT Secret + 速率限制 DashMap + WorkerDispatcher) |
| `src/config.rs` | SaaSConfig (Server/Database/Auth/Relay/RateLimit/Scheduler),多环境配置加载 |
| `src/error.rs` | SaasError 16 种变体 + HTTP 状态码映射 |
| `src/middleware.rs` | Request-ID + API-Version + 速率限制中间件 |
| `src/common.rs` | PaginatedResponse<T> + 分页工具函数 |
@@ -211,6 +215,9 @@ ZCLAW SaaS 平台为桌面端用户提供云端能力包括模型中转Key
| `src/prompt/` | Prompt 模板 + 版本管理 + OTA 检查 + 回滚 |
| `src/agent_template/` | Agent 模板 CRUD + 可见性控制 |
| `src/telemetry/` | Token 用量上报 + 模型统计 + 每日统计 + 审计摘要 |
| `src/workers/` | Worker 系统 (5 Worker: log_operation, cleanup_rate_limit, cleanup_refresh_tokens, record_usage, update_last_used) |
| `src/scheduler.rs` | 声明式 Scheduler (TOML 定时任务配置 + DB 清理任务) |
| `migrations/` | SQL 迁移文件 (Schema v6, TIMESTAMPTZ) |
### Admin 管理后台 (admin/)

View File

@@ -10,13 +10,13 @@
| ID | 问题 | 状态 | 负责人 | 目标日期 | 验证方法 |
|----|------|------|--------|---------|---------|
| SEC-V9-01 | prompt/service.rs:94,97,100 SQL 注入 | OPEN | - | - | grep "format!" prompt/service.rs 无 SQL 拼接 |
| SEC-V9-01 | prompt/service.rs:94,97,100 SQL 注入 | **FALSE_POSITIVE** | - | 2026-03-29 | 已验证: format!() 仅构建 `$N` 占位符索引,实际值通过 .bind() 参数化绑定,非 SQL 注入 |
## P1: 严重级
| ID | 问题 | 状态 | 负责人 | 目标日期 | 验证方法 |
|----|------|------|--------|---------|---------|
| BREAK-01 | LlmDriverForExtraction 无生产实现 | OPEN | - | - | grep "impl LlmDriverForExtraction" desktop/src-tauri/ |
| BREAK-01 | LlmDriverForExtraction 无生产实现 | **FIXED** | - | 2026-03-29 | `extraction_adapter.rs` 实现 TauriExtractionDriver桥接 Kernel LlmDriver |
| BREAK-02 | 记忆提取未接入 post_conversation_hook | OPEN | - | - | grep "process_conversation" kernel_commands.rs |
| BREAK-03 | 审批后不自动执行 Hand | OPEN | - | - | 验证 approval_respond 中 approved=true 触发执行 |
| BREAK-04 | pipeline-complete 事件未监听 | OPEN | - | - | grep "pipeline-complete" desktop/src/ |
@@ -38,7 +38,7 @@
| ID | 问题 | 状态 | 负责人 | 目标日期 | 验证方法 |
|----|------|------|--------|---------|---------|
| CONF-01 | 配置参数孤儿 (batch_window_ms 等) | OPEN | - | - | 实现消费或移除 |
| CONF-01 | 配置参数孤儿 (batch_window_ms 等) | **PARTIALLY_FIXED** | - | 2026-03-29 | batch_window_ms / max_concurrent_per_provider 标记为预留 (relay 配置)burst 通过 RateLimitConfig 消费 |
| SEC-V9-02 | relay 输入验证可加强 | OPEN | - | - | 添加基本校验 |
| AUDIT-01 | 前端 audit-logger 无消费者 | OPEN | - | - | grep "auditLogger" desktop/src/ |
| DEAD-04 | director.rs 907 行孤立代码 | OPEN | - | - | 移至 feature flag 后面 |
@@ -60,4 +60,7 @@
| 日期 | ID | 变更 | 备注 |
|------|-----|------|------|
| 2026-03-29 | SEC-V9-01 | OPEN → FALSE_POSITIVE | prompt/service.rs format!() 仅构建 $N 占位符,实际值通过 .bind() 参数化绑定 |
| 2026-03-29 | BREAK-01 | OPEN → FIXED | extraction_adapter.rs 实现 TauriExtractionDriver桥接 Kernel LlmDriver 到 LlmDriverForExtraction trait |
| 2026-03-29 | CONF-01 | OPEN → PARTIALLY_FIXED | Worker 系统 + Scheduler 系统上线部分配置参数已消费relay 预留参数已标注 |
| 2026-03-29 | - | V9 审计创建 | 20 个发现项 |

View File

@@ -18,9 +18,9 @@
| **文档-代码对齐率** | ~95% | ~95% | 不变 |
| **数据流连通率** | 60% (3/5) | **65%** (4/6 部分连通, 1 断裂) | 提升 |
| **Dead Code** | 28+ `#[allow(dead_code)]` | **18** (desktop) + 13 (crates) | 减少 |
| **安全漏洞** | 1 CRITICAL + 2 HIGH | **1 HIGH** + 2 MEDIUM | 改善 (CRITICAL 已修复) |
| **差距模式** | 12 个 | **16 个** (新增 4, 修复 8, 保留 4) | 净增 4 |
| **整体完成度** | ~82% | **~83%** | 升 |
| **安全漏洞** | 1 CRITICAL + 2 HIGH | **0 HIGH** + 2 MEDIUM (SEC-V9-01 确认为误报) | 改善 |
| **差距模式** | 12 个 | **14 个** (新增 4, 修复 8, 保留 4, 误报消除 2) | 改善 |
| **整体完成度** | ~82% | **~85%** | 升 |
### V8 修复确认
@@ -81,17 +81,17 @@
| 技能系统 | 70 SKILL.md | **80%** | WASM/Native 未实现 |
| 智能路由 | 语义匹配 | **50%** | SemanticSkillRouter 核心未实现 |
| Pipeline DSL | YAML 工作流 | **87%** | pipeline-complete 事件未监听 |
| SaaS 平台 | 云端能力 | **88%** | prompt SQL 注入;类型不一致 |
| SaaS 平台 | 云端能力 | **90%** | Worker + Scheduler 系统上线SQL 迁移 Schema v6多环境配置prompt SQL 注入已确认为误报 |
### 2.5 智能层评分汇总
| 模块 | 评分 | 说明 |
|------|------|------|
| zclaw-growth | **63%** | 架构设计优秀,但 3 个关键组件生产中未使用 |
| zclaw-growth | **70%** | ExtractionDriver 已修复 (BREAK-01)PromptInjector/MemoryRetriever/GrowthTracker 仍未接入 |
| intelligence/ | **78%** | 功能完整度好 |
| zclaw-pipeline | **87%** | 实现质量高 |
| zclaw-memory | **78%** | CRUD 完整,测试充分 |
| **整体** | **~83%** | 记忆闭环未接通是最大差距 |
| **整体** | **~85%** | 记忆闭环部分接通 (BREAK-01 已修复),剩余 BREAK-02 和 PromptInjector 待接入 |
---
@@ -101,7 +101,7 @@
| ID | 严重度 | 组件 | 描述 | 证据 |
|----|--------|------|------|------|
| SEC-V9-01 | **HIGH** | prompt/service.rs | SQL 注入3 处 format!() 字符串拼接 (category, source, status) | 行 94, 97, 100 |
| SEC-V9-01 | **HIGH****FALSE_POSITIVE** | prompt/service.rs | ~~SQL 注入~~: format!() 仅构建 `$N` 参数占位符索引,实际值通过 .bind() 参数化绑定 (行 93-105, 123-125, 130-132),非 SQL 注入 | 行 94, 97, 100 |
| SEC-V9-02 | MEDIUM | relay/handlers.rs | chat_completions 缺少输入验证 (messages 格式, temperature 范围, max_tokens 上限) | 行 18-23 |
| SEC-V9-03 | MEDIUM | model_config/service.rs | query.bind(format!("{}", p)) 类型强制转换 | 行 134 |
@@ -109,7 +109,7 @@
| ID | 严重度 | 组件 | 描述 | 证据 |
|----|--------|------|------|------|
| BREAK-01 | **CRITICAL** | zclaw-growth | LlmDriverForExtraction 无生产实现 — 对话不会自动产生记忆 | extractor.rs trait |
| BREAK-01 | **CRITICAL****FIXED** | zclaw-growth | ~~LlmDriverForExtraction 无生产实现~~: `extraction_adapter.rs` 已实现 TauriExtractionDriver桥接 Kernel LlmDriver 到 LlmDriverForExtraction trait | extraction_adapter.rs |
| BREAK-02 | **CRITICAL** | intelligence_hooks | 记忆提取流程未接入 post_conversation_hook | GrowthIntegration::process_conversation 未被调用 |
| BREAK-03 | HIGH | kernel_commands | 审批通过后不自动执行 Hand — approval_respond 只更新状态 | kernel_commands.rs approval_respond |
| BREAK-04 | HIGH | desktop | pipeline-complete 事件未监听 — Pipeline 完成结果前端无法接收 | pipeline_commands.rs:480 emit 无对应 listen |
@@ -144,7 +144,7 @@
|---|--------|------|---------|
| 1 | 代码存在性 | **PASS** | 11 crate 全部确认SKILL 70 vs 文档 69 |
| 2 | 调用链连通性 | **PASS** | SaaS handler 100% 连通 |
| 3 | 配置参数完整性 | **WARN** | batch_window_ms / max_concurrent_per_provider / burst 消费 |
| 3 | 配置参数完整性 | **WARN** | batch_window_ms / max_concurrent_per_provider 预留标注,burst 消费 |
| 4 | 降级策略 | **PASS** | 3 种连接模式 + 心跳降级 + 离线队列 |
| 5 | 错误处理 | **PASS** | 16 种 SaaS 错误 + 10 种前端分类 + 401 自动登出 |
| 6 | 日志完整性 | **WARN** | auth/refresh 缺日志;前端 audit-logger 无消费者 |
@@ -214,13 +214,13 @@
| # | 问题 | 修复方案 | 工作量 |
|---|------|---------|--------|
| 1 | SEC-V9-01: prompt/service.rs SQL 注入 | 将 format!() 字符串拼接改为 $N 参数化查询 (参考 agent_template 修复模式) | 1h |
| ~~1~~ | ~~SEC-V9-01: prompt/service.rs SQL 注入~~ | **已确认为误报**: format!() 仅构建 `$N` 占位符索引,值通过 .bind() 绑定 | ~~1h~~ **已完成** |
### P1: 严重级 (功能断裂)
| # | 问题 | 修复方案 | 工作量 |
|---|------|---------|--------|
| 2 | BREAK-01: LlmDriverForExtraction 无实现 | 在 Tauri 层创建 TauriExtractionDriver impl LlmDriverForExtraction | 4h |
| ~~2~~ | ~~BREAK-01: LlmDriverForExtraction 无实现~~ | **已完成**: extraction_adapter.rs 实现 TauriExtractionDriver | ~~4h~~ **已完成** |
| 3 | BREAK-02: 记忆提取未接入 post_hook | 将 GrowthIntegration::process_conversation() 接入 post_conversation_hook | 2h |
| 4 | BREAK-03: 审批后不自动执行 | 在 approval_respond 中approved=true 时自动触发对应 Hand 执行 | 3h |
| 5 | BREAK-04: pipeline-complete 未监听 | 在 workflowStore 或 pipeline-client 中添加 listen('pipeline-complete') | 1h |
@@ -255,7 +255,7 @@
| 19 | zclaw-channels 评估 | 决定保留或删除近乎空的 crate | 1h |
| 20 | trigger_update 接口不匹配 | TS 传 {id, updates} vs Rust 期望平铺参数 | 2h |
**总工作量估计**: P0 (1h) + P1 (10h) + P2 (20h) + P3 (10h) + P4 (4h) = **~45h**
**总工作量估计**: ~~P0 (1h)~~ + ~~P1 (4h 已完成)~~ + P1 (6h 剩余) + P2 (20h) + P3 (10h) + P4 (4h) = **~40h (已完成 5h)**
---
@@ -271,12 +271,12 @@
| zclaw-hands | 70% | → | 2 个 Hand 无代码 |
| zclaw-protocols | 65% | ↓ | A2A feature-gatedMCP 最小实现 |
| zclaw-pipeline | 87% | → | 高质量实现 |
| zclaw-growth | **63%** | | 3 个关键组件未接入生产 |
| zclaw-growth | **70%** | | TauriExtractionDriver 已实现 (BREAK-01 修复)PromptInjector/MemoryRetriever/GrowthTracker 仍未接入 |
| zclaw-channels | 20% | ↓ | 仅 ConsoleChannel |
| zclaw-saas | 88% | ↑ | SQL 注入修复后可到 90%+ |
| zclaw-saas | **92%** | ↑ | Worker + Scheduler + SQL 迁移 v6 + 多环境配置SQL 注入确认为误报 |
| Desktop 前端 | 82% | → | 降级策略完善 |
| Admin 后台 | 85% | → | 缺日志/同步日志页面 |
| **整体** | **~83%** | **↑** | 核心功能可用,智能层闭环待修复 |
| **整体** | **~85%** | **↑** | 核心功能可用,记忆闭环部分修复 (BREAK-01 已修复)SaaS Worker/Scheduler 系统上线 |
---
@@ -288,20 +288,21 @@ V9 审计发现的根本问题集中在一条断裂的数据链路上:
**`对话 → 记忆提取 → 存储 → 检索 → 注入 → 增强回复`**
当前只有 `检索 → 注入 → 增强回复` 在工作。记忆的"生长"依赖:
1. LlmDriverForExtraction 的实现 (BREAK-01)
2. post_conversation_hook 的接入 (BREAK-02)
3. PromptInjector 替代字符串拼接 (DEAD-01)
当前状态:
1. LlmDriverForExtraction 的实现 (BREAK-01)**已修复**: extraction_adapter.rs 实现 TauriExtractionDriver
2. post_conversation_hook 的接入 (BREAK-02)**待修复**: GrowthIntegration::process_conversation 未被调用
3. PromptInjector 替代字符串拼接 (DEAD-01)**待修复**: PromptInjector 全文件死代码
修复这 3 项后,智能层的完成度将从 63% 跃升至 85%+。
修复 BREAK-01 后,记忆提取的 LLM 驱动问题已解决。剩余 2 项修复后,智能层的完成度将从 70% 跃升至 85%+。
### 安全状态
V8 的 CRITICAL (agent_template SQL 注入) 已修复。仅剩 1 个 HIGH (prompt SQL 注入) 和 2 个 MEDIUM。SSRF 防护全面Auth 覆盖完整,密码/TOTP/加密实现安全。
V8 的 CRITICAL (agent_template SQL 注入) 已修复。V9 的 SEC-V9-01 (prompt SQL 注入) 已确认为误报 (format!() 仅构建参数占位符索引,实际值通过 .bind() 绑定)。仅剩 2 个 MEDIUM 级安全发现 (relay 输入验证、类型强制转换)。SSRF 防护全面Auth 覆盖完整,密码/TOTP/加密实现安全。
### 最大改进方向
1. **记忆闭环修复**P1 修复后用户体验显著提升
1. **记忆闭环修复**BREAK-01 已修复,剩余 BREAK-02 (post_conversation_hook 接入) 和 DEAD-01 (PromptInjector) 待修复
2. **文档更新** — 130 个命令只记录了 58 个,严重低估
3. **死代码清理** — Growth crate 3 个核心组件设计完善但未接入
3. **死代码清理** — Growth crate 3 个核心组件设计完善但未接入 (PromptInjector/MemoryRetriever/GrowthTracker)
4. **Admin 补全** — 操作日志、同步日志、设备管理页面缺失
5. **SaaS 架构优化** — Worker + Scheduler 已上线,连接池已优化,未来可迁移到 Redis 队列

View File

@@ -1,9 +1,9 @@
# ZCLAW 功能全景文档
> **版本**: v0.7.0
> **版本**: v0.8.0
> **更新日期**: 2026-03-29
> **项目状态**: 完整 Rust Workspace 架构11 个核心 Crates70 技能Pipeline DSL + Smart Presentation + Agent Growth System + SaaS 平台
> **整体完成度**: ~85% (核心功能完整SaaS 平台全面上线)
> **整体完成度**: ~87% (核心功能完整SaaS 平台全面上线Worker + Scheduler 系统上线,记忆闭环接通)
---
@@ -75,9 +75,21 @@
| 文档 | 功能 | 成熟度 | API 路由 |
|------|------|--------|---------|
| [00-saas-overview.md](08-saas-platform/00-saas-overview.md) | SaaS 平台总览 | L4 (95%) | **76+** (9 个模块) |
| [00-saas-overview.md](08-saas-platform/00-saas-overview.md) | SaaS 平台总览 | L4 (97%) | **76+** (9 个模块) |
> SaaS 后端: Axum + PostgreSQL, 9 模块 (Auth, Account, Model Config, Relay, Migration, Role, Prompt OTA, Agent Template, Telemetry), Admin 管理后台, 桌面端完整集成
>
> **架构重构成果 (Phase 0-4)**:
> - **Worker 系统**: 5 个 Worker (log_operation, cleanup_rate_limit, cleanup_refresh_tokens, record_usage, update_last_used),基于 mpsc channel 的异步调度,支持自动重试
> - **声明式 Scheduler**: TOML 配置定时任务,支持 run_on_start、灵活间隔 (30s/5m/1h/1d),无需改代码调整调度
> - **SQL 迁移系统**: Schema v6TIMESTAMPTZ 时间戳类型,从 migrations/ 目录加载 SQL 文件,向后兼容 TEXT 类型旧库
> - **多环境配置**: ZCLAW_ENV 环境选择 (development/production/test)ZCLAW_SAAS_CONFIG 精确路径ZCLAW_DATABASE_URL 覆盖
> - **连接池优化**: 50 max / 5 min 连接10s 获取超时300s 空闲超时1800s 最大生命周期
> - **速率限制优化**: 无锁 AtomicU32 读取 RPMDashMap + 60s 滑动窗口300s 定期清理
>
> **记忆闭环修复**:
> - `extraction_adapter.rs`: 实现 `TauriExtractionDriver`,将 Kernel 的 LlmDriver 桥接为 `LlmDriverForExtraction` trait
> - 对话 → 记忆提取 → 存储 → 检索 → 注入 → 增强回复 的完整闭环已接通
---
@@ -91,12 +103,14 @@
| **Pipeline 模板** | **5** |
| **Tauri 命令** | **130+** |
| **SaaS API 路由** | **76+** |
| **SaaS Workers** | **5** (log_operation, cleanup_rate_limit, cleanup_refresh_tokens, record_usage, update_last_used) |
| **SQL Schema 版本** | **v6** (TIMESTAMPTZ 类型, 声明式迁移) |
| **Zustand Store** | **14+** |
| **LLM Provider** | **8** (Kimi, Qwen, DeepSeek, Zhipu, OpenAI, Anthropic, Gemini, Local) |
| **Embedding Provider** | **6** (OpenAI, Zhipu, Doubao, Qwen, DeepSeek, Local/TF-IDF) |
| **SaaS 数据表** | **25** (PostgreSQL) |
| **内置工具** | **5** (file_read, file_write, shell_exec, web_fetch, execute_skill) |
| **Agent Growth System** | SqliteStorage + FTS5 + TF-IDF + Memory Extractor |
| **Agent Growth System** | SqliteStorage + FTS5 + TF-IDF + Memory Extractor + ExtractionAdapter (闭环) |
---
@@ -134,6 +148,8 @@ zclaw-saas — 独立运行 (Axum + PostgreSQL, 端口 8080) — 95%
| Prompt OTA | 8 | 模板 + 版本管理, OTA 检查, 回滚 |
| Agent Template | 5 | 模板 CRUD, tools/capabilities/model 绑定 |
| Telemetry | 4 | Token 用量上报, 统计聚合, 审计摘要 |
| **Worker 系统** | — | 5 个后台 Worker (log_operation, cleanup_rate_limit, cleanup_refresh_tokens, record_usage, update_last_used)mpsc 异步调度,自动重试 |
| **声明式 Scheduler** | — | TOML 配置定时任务,灵活间隔 (30s/5m/1h/1d)run_on_start内置 DB 清理 (设备 90 天) |
---
@@ -151,6 +167,7 @@ zclaw-saas — 独立运行 (Axum + PostgreSQL, 端口 8080) — 95%
| 日期 | 版本 | 变更内容 |
|------|------|---------|
| 2026-03-29 | v0.8.0 | SaaS 后端架构重构完成Worker 系统 (5 Worker + mpsc 异步调度),声明式 Scheduler (TOML 配置)SQL 迁移系统 (Schema v6 + TIMESTAMPTZ),多环境配置 (ZCLAW_ENV),连接池优化 (50 max/5 min),速率限制优化 (无锁 AtomicU32)记忆闭环修复extraction_adapter.rs 实现 TauriExtractionDriverBREAK-01 已修复 |
| 2026-03-29 | v0.7.0 | 文档同步SKILL 数量 70, Tauri 命令 130+ (含 Browser/Intelligence/Memory/CLI/SecureStorage), Hands 11 (9 启用+2 禁用), 智能层完成度修正 |
| 2026-03-28 | v0.7.0 | 基于 2026-03-28 代码状态全面更新SaaS 平台 76+ API 路由/9 模块/25 表58+ Tauri 命令8 LLM Provider3 种连接模式 |
| 2026-03-27 | v0.6.4 | 审计修复第四轮S9 消息搜索跨会话,自主授权后端守卫 |

View File

@@ -1,6 +1,6 @@
# ZCLAW 多端系统架构文档
> 版本: 1.0 | 日期: 2026-03-29 | 状态: 待审核
> 版本: 1.1 | 日期: 2026-03-29 | 状态: 已更新 (Worker + Scheduler + SQL 迁移 + 多环境配置)
---
@@ -107,6 +107,11 @@ ZCLAW 是面向中文用户的 AI Agent 桌面客户端,由 **4 个独立服
| ORM | sqlx | 编译时 SQL 检查,零开销 |
| 认证 | JWT + TOTP | 无状态鉴权 + 双因素认证 |
| 加密 | AES-256-GCM | API Key 加密存储 |
| 后台任务 | Worker trait + mpsc Channel | 异步非阻塞,支持自动重试 |
| 定时任务 | 声明式 Scheduler (TOML) | 无需改代码调整调度时间 |
| 连接池 | sqlx PgPool (50 max / 5 min) | 高并发,自动管理生命周期 |
| 迁移系统 | SQL 文件 + Schema 版本控制 | TIMESTAMPTZ 类型,向后兼容 |
| 多环境 | ZCLAW_ENV (dev/prod/test) | 配置隔离,环境变量覆盖 |
### 3.4 核心运行时 (Rust Workspace)
@@ -660,11 +665,31 @@ React UI → saas-client.ts → HTTPS REST → SaaS 后端 (:8080)
### 9.2 SaaS 后端配置
#### 配置加载优先级
```
ZCLAW_SAAS_CONFIG (精确路径) > ZCLAW_ENV (环境选择) > ./saas-config.toml (默认)
```
环境配置文件:
- `ZCLAW_ENV=development` -> `config/saas-development.toml`
- `ZCLAW_ENV=production` -> `config/saas-production.toml`
- `ZCLAW_ENV=test` -> `config/saas-test.toml`
环境变量覆盖:
- `ZCLAW_DATABASE_URL` — 覆盖数据库 URL (避免配置文件存密码)
- `ZCLAW_SAAS_JWT_SECRET` — JWT 签名密钥 (生产环境必须设置)
- `ZCLAW_TOTP_ENCRYPTION_KEY` — TOTP/AES-256-GCM 加密密钥 (hex 64 字符)
- `ZCLAW_SAAS_DEV` — 开发模式 (允许使用不安全的默认密钥)
#### 配置结构
```toml
# saas-config.toml
[server]
host = "0.0.0.0"
port = 8080
cors_origins = []
[database]
url = "postgres://postgres:123123@localhost:5432/zclaw"
@@ -672,19 +697,60 @@ url = "postgres://postgres:123123@localhost:5432/zclaw"
[auth]
jwt_expiration_hours = 24
totp_issuer = "ZCLAW SaaS"
refresh_token_hours = 168 # 7 天
[relay]
max_queue_size = 1000
max_concurrent_per_provider = 5
batch_window_ms = 50
max_concurrent_per_provider = 5 # 预留
batch_window_ms = 50 # 预留
retry_delay_ms = 1000
max_attempts = 3
[rate_limit]
requests_per_minute = 60
burst = 10
# 声明式定时任务 (新增)
[[scheduler.jobs]]
name = "cleanup-refresh-tokens"
interval = "1h"
task = "cleanup_refresh_tokens"
run_on_start = false
[[scheduler.jobs]]
name = "cleanup-rate-limit"
interval = "5m"
task = "cleanup_rate_limit"
run_on_start = false
```
#### 连接池参数
| 参数 | 值 | 说明 |
|------|-----|------|
| max_connections | 50 | 最大并发连接数 |
| min_connections | 5 | 最小空闲连接数 |
| acquire_timeout | 10s | 获取连接超时 |
| idle_timeout | 300s | 空闲连接回收 |
| max_lifetime | 1800s | 连接最大生命周期 |
#### Worker 系统
| Worker | 职责 | 触发方式 |
|--------|------|---------|
| LogOperationWorker | 异步写入操作日志 | Handler 派发 |
| CleanupRefreshTokensWorker | 清理过期 Refresh Token | Scheduler 定时 |
| CleanupRateLimitWorker | 清理过期限流条目 | Scheduler 定时 |
| RecordUsageWorker | 记录 Token 用量 | Relay Handler 派发 |
| UpdateLastUsedWorker | 更新 Key 最后使用时间 | Relay Handler 派发 |
#### SQL 迁移系统
- Schema 版本: **v6**
- 迁移目录: `crates/zclaw-saas/migrations/`
- 时间戳类型: **TIMESTAMPTZ** (新库),向后兼容 TEXT (旧库)
- 迁移文件按文件名排序执行
---
## 10. 接口设计背景与业务价值
@@ -741,4 +807,4 @@ burst = 10
---
> **文档统计**: 84 个 API 端点 | 5 个通信通道 | 12 种权限 | 4 个独立服务
> **文档统计**: 84 个 API 端点 | 5 个通信通道 | 12 种权限 | 4 个独立服务 | 5 个 Workers | 声明式 Scheduler | SQL Schema v6

View File

@@ -0,0 +1,504 @@
# DeerFlow 2.0 深度分析报告
> **项目**: [bytedance/deer-flow](https://github.com/bytedance/deer-flow)
> **版本**: 2.0 (完全重写,与 1.x 无代码共享)
> **分析日期**: 2026-03-29
> **定位**: 开源 Super Agent Harness超级智能体运行时
---
## 1. 项目概览
DeerFlow**D**eep **E**xploration and **E**fficient **R**esearch **Flow**)是字节跳动开源的 AI Agent 基础设施能编排子智能体、记忆、沙箱来执行分钟到小时级别的长周期任务。2026 年 2 月 28 日发布 2.0 后登上 GitHub Trending #1
**核心主张**: 一个可扩展的 Agent 运行时,通过 SKILL.md 技能定义 + LangGraph 编排 + 沙箱隔离,实现接近自主的复杂任务执行。
---
## 2. 系统架构
### 2.1 三层分离 + 统一反向代理
```
┌──────────────────────────────────┐
│ Nginx (Port 2026) │
│ 统一反向代理入口 │
└─────┬──────────────┬─────────────┘
│ │
/api/langgraph/* │ │ /api/*
▼ ▼
┌──────────────────────┐ ┌────────────────────────┐
│ LangGraph Server │ │ Gateway API (8001) │
│ (Port 2024) │ │ FastAPI REST │
│ │ │ │
│ Agent Runtime │ │ Models / MCP / Skills │
│ Thread Management │ │ Memory / Uploads │
│ SSE Streaming │ │ Artifacts / Threads │
│ Checkpointing │ │ │
└──────────────────────┘ └────────────────────────┘
┌──────────────────────────────────────────────┐
│ Frontend (Next.js, Port 3000) │
└──────────────────────────────────────────────┘
```
**路由规则**:
- `/api/langgraph/*` → LangGraph ServerAgent 交互、线程、流式响应)
- `/api/*` → Gateway API配置管理、文件上传、制品服务
- 其余 → Next.js 前端
### 2.2 代码规模
| 模块 | 文件数 | 技术栈 |
|------|--------|--------|
| 后端核心 (harness) | 124 .py | Python 3.12+, LangGraph, LangChain |
| 后端测试 | 69 .py | pytest |
| 前端 | 225 .ts/.tsx | Next.js 16, React 19, Tailwind v4 |
| 内置技能 | 17 个 | SKILL.md + 脚本/模板 |
---
## 3. 核心功能模块
### 3.1 Agent 编排 — Lead Agent + 中间件链
入口: `packages/harness/deerflow/agents/lead_agent/agent.py:make_lead_agent`
Lead Agent 通过工厂函数动态组装,包含 **9 层有序中间件**:
| # | 中间件 | 职责 |
|---|--------|------|
| 1 | ThreadDataMiddleware | 为线程创建隔离目录 (workspace/uploads/outputs) |
| 2 | UploadsMiddleware | 将上传文件注入对话上下文 |
| 3 | SandboxMiddleware | 获取沙箱执行环境 |
| 4 | SummarizationMiddleware | Token 接近上限时压缩上下文(可选) |
| 5 | TodoListMiddleware | Plan 模式下跟踪多步骤任务 |
| 6 | TitleMiddleware | 自动生成对话标题 |
| 7 | MemoryMiddleware | 异步排队记忆提取 |
| 8 | ViewImageMiddleware | 视觉模型图像注入 |
| 9 | ClarificationMiddleware | 拦截澄清请求(必须最后) |
额外运行时中间件:
- `LoopDetectionMiddleware` — 检测重复工具调用循环
- `SubagentLimitMiddleware` — 限制子 Agent 并发数
- `TokenUsageMiddleware` — Token 用量追踪
- `ToolErrorHandlingMiddleware` — 工具调用错误处理
- `GuardrailMiddleware` — 安全护栏(工具调用前评估)
**设计模式**: 洋葱模型Onion Model每层中间件可拦截、修改或转发请求。这与传统 Web 中间件概念一致,但应用在了 Agent 执行层面。
### 3.2 子 Agent 系统
**内置 Agent**:
- `general-purpose` — 拥有完整工具集的通用 Agent
- `bash` — 命令执行专家
**执行机制**:
- 最大 3 个子 Agent 并发(双线程池: scheduler + execution
- 15 分钟默认超时(按 Agent 可单独配置)
- 后台异步执行 + SSE 事件推送状态
- 流程: Lead Agent 调用 `task()` → 后台线程池运行子 Agent → 轮询完成 → 返回结果
### 3.3 ThreadState — 扩展的状态 Schema
```python
class ThreadState(AgentState):
messages: list[BaseMessage] # LangGraph 原生消息
sandbox: dict # 沙箱环境信息
artifacts: list[str] # 生成的文件路径
thread_data: dict # {workspace, uploads, outputs} 路径
title: str | None # 自动生成的标题
todos: list[dict] # 任务追踪 (plan mode)
viewed_images: dict # 视觉模型图像数据
```
### 3.4 技能系统
**SKILL.md 格式**:
```markdown
---
name: skill-name
description: 触发条件和工作描述
---
# 技能标题
## 工作流步骤 / 最佳实践 / 参考资源
```
**加载机制**:
- 递归扫描 `skills/{public,custom}/` 下所有 SKILL.md
- **渐进式加载** — 只有任务需要时才注入上下文,不一次性全加载
- 沙箱内通过 `/mnt/skills/` 虚拟路径访问
**17 个内置技能**:
| 技能 | 功能 | 附加资源 |
|------|------|----------|
| deep-research | 系统性深度研究方法论 | — |
| data-analysis | 数据分析 | analyze.py |
| chart-visualization | 图表可视化 | generate.js + 22 种图表参考 |
| ppt-generation | PPT 生成 | generate.py |
| image-generation | 图片生成 | generate.py + 模板 |
| video-generation | 视频生成 | generate.py |
| podcast-generation | 播客生成 | generate.py + 模板 |
| frontend-design | 前端设计 | — |
| consulting-analysis | 咨询分析 | — |
| github-deep-research | GitHub 深度研究 | github_api.py |
| skill-creator | 技能创建器 | 评估脚本 + 查看器 |
| web-design-guidelines | Web 设计指南 | — |
| surprise-me | 惊喜生成 | — |
| vercel-deploy-claimable | Vercel 部署 | deploy.sh |
| find-skills | 技能发现安装 | install-skill.sh |
| claude-to-deerflow | Claude Code 集成 | chat.sh / status.sh |
| bootstrap | 引导技能 | 模板 + 对话指南 |
### 3.5 沙箱隔离系统
**三种执行模式**:
| 模式 | Provider | 隔离级别 | 场景 |
|------|----------|----------|------|
| Local | `LocalSandboxProvider` | 无隔离 | 开发 |
| Docker | `AioSandboxProvider` | 容器级 | 生产 |
| Kubernetes | `AioSandboxProvider` + Provisioner | Pod 级 | 高安全要求 |
**虚拟路径映射**:
| 虚拟路径 | 物理映射 |
|----------|----------|
| `/mnt/user-data/workspace/` | `.deer-flow/threads/{id}/user-data/workspace` |
| `/mnt/user-data/uploads/` | `.deer-flow/threads/{id}/user-data/uploads` |
| `/mnt/user-data/outputs/` | `.deer-flow/threads/{id}/user-data/outputs` |
| `/mnt/skills/` | `deer-flow/skills/` |
**沙箱工具集**: `bash`, `ls`, `read_file`, `write_file`, `str_replace`
### 3.6 记忆系统
```
对话流 → MemoryMiddleware → Queue (防抖 30s) → Updater (LLM 提取) → memory.json
System Prompt ← 注入 Top Facts + Context ← format_memory_for_injection()
```
**记忆结构**:
- **用户上下文** — 工作背景、个人信息、当前关注点
- **对话历史** — 近期/早期/长期背景
- **事实列表** — 带置信度评分(阈值 0.7),最大 100 条,按置信度排序,受 max_injection_tokens 约束
**关键配置**:
| 参数 | 默认值 | 说明 |
|------|--------|------|
| debounce_seconds | 30 | 防抖间隔 |
| max_facts | 100 | 最大事实条数 |
| fact_confidence_threshold | 0.7 | 存储置信度阈值 |
| max_injection_tokens | 2000 | 注入上限 |
**待实现**: TF-IDF 上下文感知检索(文档已规划,尚未合并)
### 3.7 消息网关 — IM 通道集成
| 通道 | 传输方式 | 流式支持 |
|------|----------|----------|
| Telegram | Bot API (长轮询) | 等待完整响应 |
| Slack | Socket Mode | 等待完整响应 |
| 飞书/Lark | WebSocket | 流式更新消息卡片 |
所有通道使用**出站连接**,无需公网 IP。支持 `/new`, `/status`, `/models`, `/memory`, `/help` 命令。
### 3.8 模型抽象层
通过 `config.yaml``use` 字段动态加载 LangChain 类:
```yaml
models:
- name: gpt-4
use: langchain_openai:ChatOpenAI
- name: claude-sonnet-4.6
use: deerflow.models.claude_provider:ClaudeChatModel # CLI 后端
```
**支持类型**: OpenAI 兼容、Anthropic、DeepSeek、Google Gemini、OpenRouter、Codex CLI、Claude Code OAuth以及自定义 Provider通过 Python 反射机制)。
### 3.9 MCP 协议集成
支持 stdio / SSE / HTTP 三种传输方式的 MCP Server通过 `extensions_config.json` 配置,使用 `langchain-mcp-adapters` 连接。支持 OAuth 认证和工具缓存。
### 3.10 安全护栏 (Guardrails)
`GuardrailMiddleware` 在工具调用执行前评估:
- 每次工具调用通过 `GuardrailProvider` 评估
- 拒绝的调用返回错误 ToolMessageAgent 可自适应调整
- 支持 fail-closed默认阻止和 fail-open允许通过模式
---
## 4. 前端架构
### 4.1 技术栈
| 技术 | 版本 | 用途 |
|------|------|------|
| Next.js | 16.1.7 | App Router + SSR |
| React | 19.0.0 | UI 框架 |
| Tailwind CSS | 4.0.15 | 样式方案 |
| Radix UI | 多组件 | 无障碍 UI 基础 |
| @xyflow/react | 12.10.0 | 流程图可视化 |
| @tanstack/react-query | 5.x | 服务端状态 |
| better-auth | 1.3+ | 认证 |
| shiki | 3.15.0 | 代码高亮 |
| streamdown | 1.4.0 | 流式 Markdown 渲染 |
| Codemirror | 6.x | 代码编辑器 |
### 4.2 前端目录结构
```
frontend/src/
├── app/ # Next.js App Router
│ ├── api/auth/ # better-auth 认证路由
│ ├── workspace/ # 主工作区
│ │ ├── chats/ # 聊天页面
│ │ └── agents/ # 自定义 Agent 管理
│ └── mock/api/ # Mock API (开发用)
├── components/
│ ├── ai-elements/ # AI 交互组件 (25+)
│ ├── workspace/ # 工作区组件
│ ├── landing/ # 着陆页
│ └── ui/ # 基础 UI 组件
├── core/ # 核心业务逻辑
│ ├── agents/ # Agent 管理
│ ├── api/ # API 客户端
│ ├── i18n/ # 国际化
│ ├── memory/ # 记忆管理
│ ├── mcp/ # MCP 集成
│ ├── skills/ # 技能管理
│ ├── streamdown/ # 流式渲染
│ ├── threads/ # 线程管理
│ └── tools/ # 工具管理
└── hooks/ # React Hooks
```
### 4.3 前端通信
- **Agent 交互**: 通过 `@langchain/langgraph-sdk` 连接 LangGraph Server
- **配置管理**: 通过 Gateway REST API
- **流式响应**: SSE (Server-Sent Events)
- **文件上传**: multipart/form-data → Gateway → 自动转 Markdown
---
## 5. 关键实现原理
### 5.1 上下文工程 (Context Engineering)
DeerFlow 的核心设计哲学是**上下文工程** — 精心控制每次 LLM 调用的输入:
1. **渐进式技能加载** — 技能不全部注入,只在相关时加载
2. **Token 预算管理** — SummarizationMiddleware 在接近上限时压缩
3. **记忆选择性注入** — 按置信度排序,受 token 预算约束
4. **工具按需暴露** — 通过 `tool_search` 按需发现工具,不全部列出
### 5.2 线程隔离
每个对话线程拥有独立的:
- workspace 目录 — Agent 工作空间
- uploads 目录 — 用户上传文件
- outputs 目录 — 生成的制品
- 沙箱实例 — 代码执行环境
- checkpointer — 状态持久化
### 5.3 配置热重载
`AppConfig` 支持运行时重新加载Agent 配置、模型列表、工具集等可在不重启的情况下更新。
### 5.4 Agent 工厂模式
`make_lead_agent()` 是工厂函数,根据运行时配置动态组装:
- 选择模型(支持 thinking/vision 标记)
- 组装中间件链
- 加载工具集(内置 + MCP + 配置)
- 注入系统提示词(含技能上下文)
---
## 6. 技术栈全景
| 层级 | 技术 | 说明 |
|------|------|------|
| **Agent 框架** | LangGraph 1.0.x | 状态图驱动的 Agent 编排 |
| **LLM 抽象** | LangChain 1.2+ | 多模型统一接口 |
| **后端 API** | FastAPI | REST + SSE |
| **前端框架** | Next.js 16 + React 19 | App Router |
| **样式** | Tailwind CSS v4 | 原子化 CSS |
| **沙箱** | Docker / Kubernetes | 容器级隔离 |
| **MCP** | langchain-mcp-adapters | 工具协议集成 |
| **IM 集成** | lark-oapi / slack-sdk / python-telegram-bot | 消息通道 |
| **搜索** | Tavily / Firecrawl / DuckDuckGo / InfoQuest | 信息获取 |
| **认证** | better-auth | Web 端用户认证 |
| **可观测** | LangSmith | 链路追踪 |
| **包管理** | uv (Python) + pnpm (JS) | monorepo workspace |
| **反向代理** | Nginx | 统一入口 |
| **语言要求** | Python 3.12+, Node.js 22+ | — |
| **License** | MIT | 开源 |
---
## 7. 优势分析
### 7.1 架构优势
1. **中间件链模式** — 9 层有序中间件,横切关注点高度模块化,新增功能只需添加中间件
2. **渐进式上下文工程** — 不是一股脑注入所有信息,而是按需、按预算精确控制 LLM 输入
3. **技能热插拔** — SKILL.md 格式简单直观,用户可自行创建和安装技能
4. **模型无关** — 通过 LangChain 类路径动态加载,支持任意 OpenAI 兼容 API
5. **三层分离** — LangGraph Server / Gateway API / Frontend 各司其职,独立扩展
### 7.2 生态优势
1. **LangGraph 生态** — 直接受益于 LangChain/LangGraph 社区的持续迭代
2. **MCP 协议** — 无缝接入 MCP 工具生态
3. **多 IM 通道** — Telegram/Slack/飞书开箱即用
4. **字节跳动背书** — 企业级投入InfoQuest 等增值服务集成
5. **社区活跃** — GitHub Trending #1,多语言 README活跃的 Issue/PR
### 7.3 工程优势
1. **完整的测试覆盖** — 69 个测试文件,覆盖核心路径
2. **多沙箱模式** — 从开发到生产无缝切换
3. **配置版本管理** — config_version + `make config-upgrade` 平滑升级
4. **嵌入式 Python 客户端**`deerflow-harness` 可作为独立包使用
---
## 8. 局限性分析
### 8.1 架构局限
1. **Python 性能瓶颈** — Python 3.12 + asyncio 在高并发场景下受限GIL 限制了真正的并行
2. **LangGraph 耦合** — 核心编排绑定 LangGraph迁移成本高
3. **单 Lead Agent 模型** — 不支持多 Agent 协作(仅 Lead → Sub 单向委托),缺少 Agent 间协商
4. **文件系统状态** — Checkpointer 默认用 SQLite 本地文件,生产需外部化
### 8.2 功能局限
1. **记忆系统不成熟** — TF-IDF 上下文感知检索尚未实现,当前仅按置信度排序
2. **无实时协作** — 不支持多用户同时操作同一线程
3. **沙箱冷启动** — Docker 容器启动有延迟,影响首次响应时间
4. **技能质量参差** — 17 个内置技能,深度和完成度不一
5. **无内置 RAG** — 知识检索依赖外部搜索 API无本地知识库
### 8.3 运维局限
1. **依赖链复杂** — LangGraph + LangChain + FastAPI + Docker + Nginx + MCP 等,部署门槛高
2. **无内置用户管理** — Web 端认证由 better-auth 提供,但无企业级 RBAC
3. **成本控制有限** — Token 用量追踪有,但无细粒度的配额/限流机制
4. **可观测性依赖外部** — 需要 LangSmith 才能获得完整链路追踪
---
## 9. 适用场景
### 9.1 最佳场景
| 场景 | 适配度 | 原因 |
|------|--------|------|
| 深度研究任务 | ⭐⭐⭐⭐⭐ | 原生设计目标deep-research 技能成熟 |
| 内容生成PPT/视频/播客) | ⭐⭐⭐⭐⭐ | 丰富的多媒体生成技能 |
| 数据分析与可视化 | ⭐⭐⭐⭐ | chart-visualization + data-analysis 组合 |
| IM Bot 开发 | ⭐⭐⭐⭐ | 三大 IM 通道开箱即用 |
| 编程辅助(代码生成/调试) | ⭐⭐⭐⭐ | 沙箱 + 工具链完善 |
### 9.2 勉强场景
| 场景 | 适配度 | 原因 |
|------|--------|------|
| 企业级 SaaS 平台 | ⭐⭐ | 缺少 RBAC、审计、多租户 |
| 实时协作应用 | ⭐⭐ | 无多用户并发支持 |
| 边缘/离线部署 | ⭐⭐ | 依赖云 API 和 Docker |
### 9.3 不适用场景
| 场景 | 原因 |
|------|------|
| 低延迟实时系统 | Python + LLM API 调用固有延迟 |
| 高并发生产服务 | 单进程 + GIL 限制 |
| 安全合规要求高的环境 | 沙箱隔离非零信任 |
---
## 10. 与同类项目对比
| 维度 | DeerFlow 2.0 | OpenHands | CrewAI | AutoGen | Devin |
|------|-------------|-----------|--------|---------|-------|
| **定位** | Super Agent Harness | 软件开发 Agent | 多 Agent 框架 | 多 Agent 对话 | 自主编程 Agent |
| **编排模型** | Lead + Sub (LangGraph) | 单 Agent + Action | Crew + Task | GroupChat | 单 Agent |
| **沙箱隔离** | Docker/K8s | Docker | 无内置 | 无内置 | 自研 |
| **技能扩展** | SKILL.md 热插拔 | 无 | 工具定义 | 函数注册 | 无 |
| **IM 集成** | 3 通道 | 无 | 无 | 无 | 无 |
| **记忆系统** | LLM 提取 + JSON | 无 | 无 | 无 | 有 |
| **开源** | MIT | MIT | MIT | MIT | 不开源 |
| **语言** | Python | Python | Python | Python | 未知 |
| **背书** | 字节跳动 | All Hands AI | CrewAI Inc | Microsoft | Cognition |
---
## 11. 未来发展方向(推断)
### 11.1 短期 (3-6 个月)
1. **记忆系统增强** — TF-IDF 上下文感知检索合并,记忆质量提升
2. **更多 IM 通道** — 微信、钉钉等国内平台集成
3. **技能市场** — 社区技能共享与评分系统
4. **多 Agent 协作** — 从单向委托升级为多 Agent 协商
### 11.2 中期 (6-12 个月)
1. **企业级功能** — RBAC、审计日志、多租户、配额管理
2. **RAG 集成** — 本地知识库支持,减少对外部搜索的依赖
3. **模型微调** — 针对 Agent 任务的专项微调指南
4. **多模态增强** — 更强的图像/视频理解与生成
### 11.3 长期 (12+ 个月)
1. **Agent 操作系统** — 从工具到真正的自主 Agent 平台
2. **去中心化** — 支持 Agent-to-Agent 通信协议
3. **端侧部署** — 支持模型本地化运行,降低 API 依赖
---
## 12. 对 ZCLAW 的借鉴价值
| DeerFlow 设计 | 借鉴价值 | 实施难度 |
|---------------|----------|----------|
| **中间件链模式** | Agent 执行前的横切关注点模块化ZCLAW Kernel 可引入类似模式 | 中 |
| **渐进式技能加载** | SKILL.md 按需注入而非全量,减少 Token 浪费 | 低 |
| **LLM 驱动记忆提取** | 用 LLM 从对话中提取结构化记忆,比规则提取更鲁棒 | 中 |
| **沙箱虚拟路径** | 统一的路径抽象,代码与平台无关 | 高 |
| **IM 通道架构** | Channel 抽象 + MessageBus 模式,易于扩展新通道 | 低 |
| **模型动态加载** | `use` 字段 + 反射机制,无需硬编码 Provider | 中 |
| **Guardrail 安全护栏** | 工具调用前评估,可配置的拦截策略 | 中 |
**核心差异**: DeerFlow 是 Python 服务端架构ZCLAW 是 Rust + TS 桌面架构。技术栈不同,但在 **Agent 编排模式、技能系统设计、记忆系统架构** 等领域无关的设计层面DeerFlow 提供了成熟的参考。
---
## 13. 项目关键数据
| 指标 | 值 |
|------|-----|
| GitHub Stars | 20k+ (2026-03 Trending #1) |
| License | MIT |
| 主要语言 | Python (后端), TypeScript (前端) |
| Python 版本 | 3.12+ |
| Node.js 版本 | 22+ |
| 后端源文件 | 124 .py (harness) |
| 前端源文件 | 225 .ts/.tsx |
| 测试文件 | 69 .py |
| 内置技能 | 17 个 |
| 支持的 IM | Telegram, Slack, 飞书 |
| 沙箱模式 | Local / Docker / Kubernetes |
| 端口 | 2026 (Nginx), 2024 (LangGraph), 8001 (Gateway), 3000 (Frontend) |
---
*本分析基于 DeerFlow 2.0 main 分支 (截至 2026-03-29) 的源代码和文档。*

View File

@@ -1,5 +1,5 @@
# ZCLAW Full Stack Start Script
# Starts: PostgreSQL (Docker) -> SaaS Backend -> ChromeDriver (optional) -> Tauri Desktop
# Starts: PostgreSQL (Docker) -> SaaS Backend -> Admin Web -> ChromeDriver (optional) -> Tauri Desktop
#
# NOTE: ZCLAW now uses internal Kernel (zclaw-kernel) for all operations.
# No external ZCLAW runtime is required.
@@ -33,7 +33,7 @@ Usage: .\start-all.ps1 [options]
Options:
-DesktopOnly Start desktop only (skip ChromeDriver + SaaS + PostgreSQL)
-NoBrowser Skip ChromeDriver startup
-NoSaas Skip PostgreSQL + SaaS backend startup
-NoSaas Skip PostgreSQL + SaaS backend + Admin dashboard startup
-Dev Development mode (hot reload)
-Stop Stop all services
-Help Show this help
@@ -59,13 +59,16 @@ if ($Stop) {
Get-Process -Name "chromedriver" -ErrorAction SilentlyContinue | Stop-Process -Force
ok "ChromeDriver stopped"
# Stop SaaS backend
Get-Process -Name "zclaw-saas" -ErrorAction SilentlyContinue | Stop-Process -Force
# Stop SaaS backend (kill process tree)
Get-Process -Name "zclaw-saas" -ErrorAction SilentlyContinue | ForEach-Object {
& taskkill /T /F /PID $_.Id 2>$null
ok "Stopped SaaS backend process tree (PID: $($_.Id))"
}
$port8080 = netstat -ano | Select-String ":8080.*LISTENING"
if ($port8080) {
$pid8080 = ($port8080 -split '\s+')[-1]
if ($pid8080 -match '^\d+$') {
Stop-Process -Id $pid8080 -Force -ErrorAction SilentlyContinue
& taskkill /T /F /PID $pid8080 2>$null
ok "Stopped SaaS backend on port 8080 (PID: $pid8080)"
}
}
@@ -75,11 +78,21 @@ if ($Stop) {
if ($port4200) {
$pid4200 = ($port4200 -split '\s+')[-1]
if ($pid4200 -match '^\d+$') {
Stop-Process -Id $pid4200 -Force -ErrorAction SilentlyContinue
& taskkill /T /F /PID $pid4200 2>$null
ok "Stopped process on port 4200 (PID: $pid4200)"
}
}
# Stop Admin dev server (kill process tree to ensure node.exe children die)
$port3000 = netstat -ano | Select-String ":3000.*LISTENING"
if ($port3000) {
$pid3000 = ($port3000 -split '\s+')[-1]
if ($pid3000 -match '^\d+$') {
& taskkill /T /F /PID $pid3000 2>$null
ok "Stopped Admin dev server on port 3000 (PID: $pid3000)"
}
}
# Stop Tauri/ZClaw
Get-Process -Name "ZClaw" -ErrorAction SilentlyContinue | Stop-Process -Force
Get-Process -Name "desktop" -ErrorAction SilentlyContinue | Stop-Process -Force
@@ -90,7 +103,7 @@ if ($Stop) {
if ($port1420) {
$pid1420 = ($port1420 -split '\s+')[-1]
if ($pid1420 -match '^\d+$') {
Stop-Process -Id $pid1420 -Force -ErrorAction SilentlyContinue
& taskkill /T /F /PID $pid1420 2>$null
ok "Killed process on port 1420 (PID: $pid1420)"
}
}
@@ -110,10 +123,28 @@ $Jobs = @()
function Cleanup {
info "Cleaning up..."
# Kill tracked process trees (parent + all children)
foreach ($job in $Jobs) {
if ($job -and !$job.HasExited) {
info "Stopping $($job.ProcessName) (PID: $($job.Id))"
try { $job.Kill() } catch {}
info "Stopping $($job.ProcessName) (PID: $($job.Id)) and child processes"
try {
# taskkill /T kills the entire process tree, not just the parent
& taskkill /T /F /PID $job.Id 2>$null
if (!$job.HasExited) { $job.Kill() }
} catch {
try { $job.Kill() } catch {}
}
}
}
# Fallback: kill processes by known ports
foreach ($port in @(8080, 3000)) {
$listening = netstat -ano | Select-String ":${port}.*LISTENING"
if ($listening) {
$pid = ($listening -split '\s+')[-1]
if ($pid -match '^\d+$') {
info "Killing orphan process on port $port (PID: $pid)"
& taskkill /T /F /PID $pid 2>$null
}
}
}
}
@@ -203,7 +234,48 @@ if (-not $NoSaas) {
Write-Host ""
# 3. ChromeDriver (optional - for Browser Hand automation)
# 3. Admin Web (Next.js management dashboard on port 3000)
if (-not $NoSaas) {
info "Checking Admin dashboard..."
$port3000 = netstat -ano | Select-String ":3000.*LISTENING"
if ($port3000) {
$pid3000 = ($port3000 -split '\s+')[-1]
if ($pid3000 -match '^\d+$') {
ok "Admin dashboard already running on port 3000 (PID: $pid3000)"
}
} else {
if (Test-Path "$ScriptDir\admin\package.json") {
info "Starting Admin dashboard on port 3000..."
Set-Location "$ScriptDir\admin"
if ($Dev) {
$proc = Start-Process -FilePath "cmd.exe" -ArgumentList "/c cd /d `"$ScriptDir\admin`" && pnpm dev" -PassThru -WindowStyle Minimized
} else {
$proc = Start-Process -FilePath "cmd.exe" -ArgumentList "/c cd /d `"$ScriptDir\admin`" && pnpm dev" -PassThru -WindowStyle Minimized
}
$Jobs += $proc
Set-Location $ScriptDir
Start-Sleep -Seconds 5
$port3000Check = netstat -ano | Select-String ":3000.*LISTENING"
if ($port3000Check) {
ok "Admin dashboard started on port 3000 (PID: $($proc.Id))"
} else {
warn "Admin dashboard may still be starting. Check http://localhost:3000"
}
} else {
warn "Admin directory not found. Skipping."
}
}
} else {
info "Skipping Admin dashboard"
}
Write-Host ""
# 4. ChromeDriver (optional - for Browser Hand automation)
if (-not $NoBrowser) {
info "Checking ChromeDriver..."
@@ -236,7 +308,7 @@ if (-not $NoBrowser) {
Write-Host ""
# 4. Start Tauri Desktop
# 5. Start Tauri Desktop
info "Starting ZCLAW Desktop..."
Set-Location "$ScriptDir/desktop"

View File

@@ -1 +1 @@
{"rustc_fingerprint":9233652427518751716,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.93.1 (01f6ddf75 2026-02-11)\nbinary: rustc\ncommit-hash: 01f6ddf7588f42ae2d7eb0a2f21d44e8e96674cf\ncommit-date: 2026-02-11\nhost: x86_64-pc-windows-msvc\nrelease: 1.93.1\nLLVM version: 21.1.8\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\n___.lib\n___.dll\nC:\\Users\\szend\\.rustup\\toolchains\\stable-x86_64-pc-windows-msvc\npacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"msvc\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""},"12004014463585500860":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\n___.lib\n___.dll\nC:\\Users\\szend\\.rustup\\toolchains\\stable-x86_64-pc-windows-msvc\npacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"msvc\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""}},"successes":{}}
{"rustc_fingerprint":5915500824126575890,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\n___.lib\n___.dll\nC:\\Users\\szend\\.rustup\\toolchains\\stable-x86_64-pc-windows-msvc\npacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"msvc\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.93.1 (01f6ddf75 2026-02-11)\nbinary: rustc\ncommit-hash: 01f6ddf7588f42ae2d7eb0a2f21d44e8e96674cf\ncommit-date: 2026-02-11\nhost: x86_64-pc-windows-msvc\nrelease: 1.93.1\nLLVM version: 21.1.8\n","stderr":""}},"successes":{}}

View File

@@ -1,27 +1,5 @@
Blocking waiting for file lock on build directory
26.141648000s INFO prepare_target{force=false package_id=desktop v0.1.0 (G:\ZClaw_openfang\desktop\src-tauri) target="desktop_lib"}: cargo::core::compiler::fingerprint: dependency on `zclaw_runtime` is newer than we are 13419189849.835904700s > 13419188800.386462300s "G:\\ZClaw_openfang\\crates\\zclaw-kernel"
26.143007100s INFO prepare_target{force=false package_id=desktop v0.1.0 (G:\ZClaw_openfang\desktop\src-tauri) target="desktop_lib"}: cargo::core::compiler::fingerprint: dependency on `zclaw_runtime` is newer than we are 13419189849.835904700s > 13419188801.175314500s "G:\\ZClaw_openfang\\crates\\zclaw-pipeline"
26.143125500s INFO prepare_target{force=false package_id=desktop v0.1.0 (G:\ZClaw_openfang\desktop\src-tauri) target="desktop_lib"}: cargo::core::compiler::fingerprint: dependency on `zclaw_runtime` is newer than we are 13419189849.835904700s > 13419188802.111321400s "G:\\ZClaw_openfang\\desktop\\src-tauri"
26.167874100s INFO prepare_target{force=false package_id=desktop v0.1.0 (G:\ZClaw_openfang\desktop\src-tauri) target="desktop_lib"}: cargo::core::compiler::fingerprint: fingerprint dirty for desktop v0.1.0 (G:\ZClaw_openfang\desktop\src-tauri)/Check { test: false }/TargetInner { ..: lib_target("desktop_lib", ["staticlib", "cdylib", "rlib"], "G:\\ZClaw_openfang\\desktop\\src-tauri\\src\\lib.rs", Edition2021) }
26.168892700s INFO prepare_target{force=false package_id=desktop v0.1.0 (G:\ZClaw_openfang\desktop\src-tauri) target="desktop_lib"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDependency { name: "zclaw_runtime", dep_mtime: FileTime { seconds: 13419189849, nanos: 835904700 }, max_mtime: FileTime { seconds: 13419188802, nanos: 111321400 } })
26.432021100s INFO prepare_target{force=false package_id=zclaw-kernel v0.1.0 (G:\ZClaw_openfang\crates\zclaw-kernel) target="zclaw_kernel"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-kernel v0.1.0 (G:\ZClaw_openfang\crates\zclaw-kernel)/Check { test: false }/TargetInner { name_inferred: true, ..: lib_target("zclaw_kernel", ["lib"], "G:\\ZClaw_openfang\\crates\\zclaw-kernel\\src\\lib.rs", Edition2021) }
26.432130600s INFO prepare_target{force=false package_id=zclaw-kernel v0.1.0 (G:\ZClaw_openfang\crates\zclaw-kernel) target="zclaw_kernel"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDependency { name: "zclaw_runtime", dep_mtime: FileTime { seconds: 13419189849, nanos: 835904700 }, max_mtime: FileTime { seconds: 13419188800, nanos: 386462300 } })
26.491595500s INFO prepare_target{force=false package_id=zclaw-pipeline v0.1.0 (G:\ZClaw_openfang\crates\zclaw-pipeline) target="zclaw_pipeline"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-pipeline v0.1.0 (G:\ZClaw_openfang\crates\zclaw-pipeline)/Check { test: false }/TargetInner { name_inferred: true, ..: lib_target("zclaw_pipeline", ["lib"], "G:\\ZClaw_openfang\\crates\\zclaw-pipeline\\src\\lib.rs", Edition2021) }
26.491640500s INFO prepare_target{force=false package_id=zclaw-pipeline v0.1.0 (G:\ZClaw_openfang\crates\zclaw-pipeline) target="zclaw_pipeline"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDependency { name: "zclaw_runtime", dep_mtime: FileTime { seconds: 13419189849, nanos: 835904700 }, max_mtime: FileTime { seconds: 13419188801, nanos: 175314500 } })
26.494184300s INFO prepare_target{force=false package_id=desktop v0.1.0 (G:\ZClaw_openfang\desktop\src-tauri) target="desktop_lib"}: cargo::core::compiler::fingerprint: dependency on `zclaw_runtime` is newer than we are 13419189849.835904700s > 13419188802.111321400s "G:\\ZClaw_openfang\\desktop\\src-tauri"
26.516629900s INFO prepare_target{force=false package_id=desktop v0.1.0 (G:\ZClaw_openfang\desktop\src-tauri) target="desktop_lib"}: cargo::core::compiler::fingerprint: fingerprint dirty for desktop v0.1.0 (G:\ZClaw_openfang\desktop\src-tauri)/Check { test: true }/TargetInner { ..: lib_target("desktop_lib", ["staticlib", "cdylib", "rlib"], "G:\\ZClaw_openfang\\desktop\\src-tauri\\src\\lib.rs", Edition2021) }
26.516701400s INFO prepare_target{force=false package_id=desktop v0.1.0 (G:\ZClaw_openfang\desktop\src-tauri) target="desktop_lib"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDependency { name: "zclaw_runtime", dep_mtime: FileTime { seconds: 13419189849, nanos: 835904700 }, max_mtime: FileTime { seconds: 13419188802, nanos: 111321400 } })
26.533083700s INFO prepare_target{force=false package_id=desktop v0.1.0 (G:\ZClaw_openfang\desktop\src-tauri) target="desktop"}: cargo::core::compiler::fingerprint: dependency on `zclaw_runtime` is newer than we are 13419189849.835904700s > 13419188810.586678400s "G:\\ZClaw_openfang\\desktop\\src-tauri"
26.542060700s INFO prepare_target{force=false package_id=desktop v0.1.0 (G:\ZClaw_openfang\desktop\src-tauri) target="desktop"}: cargo::core::compiler::fingerprint: fingerprint dirty for desktop v0.1.0 (G:\ZClaw_openfang\desktop\src-tauri)/Check { test: false }/TargetInner { name: "desktop", doc: true, ..: with_path("G:\\ZClaw_openfang\\desktop\\src-tauri\\src\\main.rs", Edition2021) }
26.542108400s INFO prepare_target{force=false package_id=desktop v0.1.0 (G:\ZClaw_openfang\desktop\src-tauri) target="desktop"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDependency { name: "zclaw_runtime", dep_mtime: FileTime { seconds: 13419189849, nanos: 835904700 }, max_mtime: FileTime { seconds: 13419188810, nanos: 586678400 } })
26.546169800s INFO prepare_target{force=false package_id=desktop v0.1.0 (G:\ZClaw_openfang\desktop\src-tauri) target="desktop"}: cargo::core::compiler::fingerprint: dependency on `zclaw_runtime` is newer than we are 13419189849.835904700s > 13419188810.586678400s "G:\\ZClaw_openfang\\desktop\\src-tauri"
26.551831400s INFO prepare_target{force=false package_id=desktop v0.1.0 (G:\ZClaw_openfang\desktop\src-tauri) target="desktop"}: cargo::core::compiler::fingerprint: fingerprint dirty for desktop v0.1.0 (G:\ZClaw_openfang\desktop\src-tauri)/Check { test: true }/TargetInner { name: "desktop", doc: true, ..: with_path("G:\\ZClaw_openfang\\desktop\\src-tauri\\src\\main.rs", Edition2021) }
26.551892700s INFO prepare_target{force=false package_id=desktop v0.1.0 (G:\ZClaw_openfang\desktop\src-tauri) target="desktop"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDependency { name: "zclaw_runtime", dep_mtime: FileTime { seconds: 13419189849, nanos: 835904700 }, max_mtime: FileTime { seconds: 13419188810, nanos: 586678400 } })
26.581214400s INFO prepare_target{force=false package_id=zclaw-kernel v0.1.0 (G:\ZClaw_openfang\crates\zclaw-kernel) target="zclaw_kernel"}: cargo::core::compiler::fingerprint: dependency on `zclaw_runtime` is newer than we are 13419189849.835904700s > 13419188800.388008800s "G:\\ZClaw_openfang\\crates\\zclaw-kernel"
26.608115700s INFO prepare_target{force=false package_id=zclaw-kernel v0.1.0 (G:\ZClaw_openfang\crates\zclaw-kernel) target="zclaw_kernel"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-kernel v0.1.0 (G:\ZClaw_openfang\crates\zclaw-kernel)/Check { test: true }/TargetInner { name_inferred: true, ..: lib_target("zclaw_kernel", ["lib"], "G:\\ZClaw_openfang\\crates\\zclaw-kernel\\src\\lib.rs", Edition2021) }
26.608166000s INFO prepare_target{force=false package_id=zclaw-kernel v0.1.0 (G:\ZClaw_openfang\crates\zclaw-kernel) target="zclaw_kernel"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDependency { name: "zclaw_runtime", dep_mtime: FileTime { seconds: 13419189849, nanos: 835904700 }, max_mtime: FileTime { seconds: 13419188800, nanos: 388008800 } })
26.611484000s INFO prepare_target{force=false package_id=zclaw-memory v0.1.0 (G:\ZClaw_openfang\crates\zclaw-memory) target="zclaw_memory"}: cargo::core::compiler::fingerprint: fingerprint error for zclaw-memory v0.1.0 (G:\ZClaw_openfang\crates\zclaw-memory)/Check { test: true }/TargetInner { name_inferred: true, ..: lib_target("zclaw_memory", ["lib"], "G:\\ZClaw_openfang\\crates\\zclaw-memory\\src\\lib.rs", Edition2021) }
26.611511100s INFO prepare_target{force=false package_id=zclaw-memory v0.1.0 (G:\ZClaw_openfang\crates\zclaw-memory) target="zclaw_memory"}: cargo::core::compiler::fingerprint: err: failed to read `G:\ZClaw_openfang\target\debug\.fingerprint\zclaw-memory-258e8bafe81b73c9\test-lib-zclaw_memory`
0.868534600s INFO prepare_target{force=false package_id=zclaw-memory v0.1.0 (G:\ZClaw_openfang\crates\zclaw-memory) target="zclaw_memory"}: cargo::core::compiler::fingerprint: fingerprint error for zclaw-memory v0.1.0 (G:\ZClaw_openfang\crates\zclaw-memory)/Check { test: true }/TargetInner { name_inferred: true, ..: lib_target("zclaw_memory", ["lib"], "G:\\ZClaw_openfang\\crates\\zclaw-memory\\src\\lib.rs", Edition2021) }
0.868582700s INFO prepare_target{force=false package_id=zclaw-memory v0.1.0 (G:\ZClaw_openfang\crates\zclaw-memory) target="zclaw_memory"}: cargo::core::compiler::fingerprint: err: failed to read `G:\ZClaw_openfang\target\debug\.fingerprint\zclaw-memory-258e8bafe81b73c9\test-lib-zclaw_memory`
Caused by:
系统找不到指定的文件。 (os error 2)
@@ -48,47 +26,39 @@ Stack backtrace:
18: git_midx_writer_dump
19: BaseThreadInitThunk
20: RtlUserThreadStart
26.634729900s INFO prepare_target{force=false package_id=zclaw-pipeline v0.1.0 (G:\ZClaw_openfang\crates\zclaw-pipeline) target="zclaw_pipeline"}: cargo::core::compiler::fingerprint: dependency on `zclaw_runtime` is newer than we are 13419189849.835904700s > 13419188801.175314500s "G:\\ZClaw_openfang\\crates\\zclaw-pipeline"
26.635409500s INFO prepare_target{force=false package_id=zclaw-pipeline v0.1.0 (G:\ZClaw_openfang\crates\zclaw-pipeline) target="zclaw_pipeline"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-pipeline v0.1.0 (G:\ZClaw_openfang\crates\zclaw-pipeline)/Check { test: true }/TargetInner { name_inferred: true, ..: lib_target("zclaw_pipeline", ["lib"], "G:\\ZClaw_openfang\\crates\\zclaw-pipeline\\src\\lib.rs", Edition2021) }
26.635438300s INFO prepare_target{force=false package_id=zclaw-pipeline v0.1.0 (G:\ZClaw_openfang\crates\zclaw-pipeline) target="zclaw_pipeline"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDependency { name: "zclaw_runtime", dep_mtime: FileTime { seconds: 13419189849, nanos: 835904700 }, max_mtime: FileTime { seconds: 13419188801, nanos: 175314500 } })
26.760317000s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="zclaw_saas"}: cargo::core::compiler::fingerprint: stale: changed "G:\\ZClaw_openfang\\crates\\zclaw-saas\\src\\relay\\handlers.rs"
26.760361200s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="zclaw_saas"}: cargo::core::compiler::fingerprint: (vs) "G:\\ZClaw_openfang\\target\\debug\\.fingerprint\\zclaw-saas-24b5f21f9faf9a21\\dep-lib-zclaw_saas"
26.760368700s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="zclaw_saas"}: cargo::core::compiler::fingerprint: FileTime { seconds: 13419189846, nanos: 514689800 } < FileTime { seconds: 13419222793, nanos: 303946200 }
26.761464400s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="zclaw_saas"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas)/Check { test: false }/TargetInner { name_inferred: true, ..: lib_target("zclaw_saas", ["lib"], "G:\\ZClaw_openfang\\crates\\zclaw-saas\\src\\lib.rs", Edition2021) }
26.761496600s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="zclaw_saas"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleItem(ChangedFile { reference: "G:\\ZClaw_openfang\\target\\debug\\.fingerprint\\zclaw-saas-24b5f21f9faf9a21\\dep-lib-zclaw_saas", reference_mtime: FileTime { seconds: 13419189846, nanos: 514689800 }, stale: "G:\\ZClaw_openfang\\crates\\zclaw-saas\\src\\relay\\handlers.rs", stale_mtime: FileTime { seconds: 13419222793, nanos: 303946200 } }))
26.777582700s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="zclaw_saas"}: cargo::core::compiler::fingerprint: stale: changed "G:\\ZClaw_openfang\\crates\\zclaw-saas\\src\\role\\mod.rs"
26.777612500s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="zclaw_saas"}: cargo::core::compiler::fingerprint: (vs) "G:\\ZClaw_openfang\\target\\debug\\.fingerprint\\zclaw-saas-bb901b542dca6f4e\\dep-test-lib-zclaw_saas"
26.777623600s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="zclaw_saas"}: cargo::core::compiler::fingerprint: FileTime { seconds: 13419189846, nanos: 514689800 } < FileTime { seconds: 13419224259, nanos: 500558800 }
26.778587000s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="zclaw_saas"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas)/Check { test: true }/TargetInner { name_inferred: true, ..: lib_target("zclaw_saas", ["lib"], "G:\\ZClaw_openfang\\crates\\zclaw-saas\\src\\lib.rs", Edition2021) }
26.778635400s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="zclaw_saas"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleItem(ChangedFile { reference: "G:\\ZClaw_openfang\\target\\debug\\.fingerprint\\zclaw-saas-bb901b542dca6f4e\\dep-test-lib-zclaw_saas", reference_mtime: FileTime { seconds: 13419189846, nanos: 514689800 }, stale: "G:\\ZClaw_openfang\\crates\\zclaw-saas\\src\\role\\mod.rs", stale_mtime: FileTime { seconds: 13419224259, nanos: 500558800 } }))
26.788720900s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="zclaw-saas"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas)/Check { test: false }/TargetInner { name: "zclaw-saas", doc: true, ..: with_path("G:\\ZClaw_openfang\\crates\\zclaw-saas\\src\\main.rs", Edition2021) }
26.788768300s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="zclaw-saas"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDepFingerprint { name: "zclaw_saas" })
26.802265500s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="zclaw-saas"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas)/Check { test: true }/TargetInner { name: "zclaw-saas", doc: true, ..: with_path("G:\\ZClaw_openfang\\crates\\zclaw-saas\\src\\main.rs", Edition2021) }
26.802321100s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="zclaw-saas"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDepFingerprint { name: "zclaw_saas" })
26.817890600s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="account_test"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas)/Check { test: true }/TargetInner { kind: "test", name: "account_test", benched: false, ..: with_path("G:\\ZClaw_openfang\\crates\\zclaw-saas\\tests\\account_test.rs", Edition2021) }
26.817958500s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="account_test"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDepFingerprint { name: "zclaw_saas" })
26.849051600s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="agent_template_test"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas)/Check { test: true }/TargetInner { kind: "test", name: "agent_template_test", benched: false, ..: with_path("G:\\ZClaw_openfang\\crates\\zclaw-saas\\tests\\agent_template_test.rs", Edition2021) }
26.849149700s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="agent_template_test"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDepFingerprint { name: "zclaw_saas" })
26.887225300s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="auth_test"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas)/Check { test: true }/TargetInner { kind: "test", name: "auth_test", benched: false, ..: with_path("G:\\ZClaw_openfang\\crates\\zclaw-saas\\tests\\auth_test.rs", Edition2021) }
26.887279900s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="auth_test"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDepFingerprint { name: "zclaw_saas" })
26.917072200s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="migration_test"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas)/Check { test: true }/TargetInner { kind: "test", name: "migration_test", benched: false, ..: with_path("G:\\ZClaw_openfang\\crates\\zclaw-saas\\tests\\migration_test.rs", Edition2021) }
26.917132300s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="migration_test"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDepFingerprint { name: "zclaw_saas" })
26.927571400s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="model_config_test"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas)/Check { test: true }/TargetInner { kind: "test", name: "model_config_test", benched: false, ..: with_path("G:\\ZClaw_openfang\\crates\\zclaw-saas\\tests\\model_config_test.rs", Edition2021) }
26.927621500s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="model_config_test"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDepFingerprint { name: "zclaw_saas" })
26.939521500s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="prompt_test"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas)/Check { test: true }/TargetInner { kind: "test", name: "prompt_test", benched: false, ..: with_path("G:\\ZClaw_openfang\\crates\\zclaw-saas\\tests\\prompt_test.rs", Edition2021) }
26.939571900s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="prompt_test"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDepFingerprint { name: "zclaw_saas" })
26.952870300s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="relay_test"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas)/Check { test: true }/TargetInner { kind: "test", name: "relay_test", benched: false, ..: with_path("G:\\ZClaw_openfang\\crates\\zclaw-saas\\tests\\relay_test.rs", Edition2021) }
26.952917600s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="relay_test"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDepFingerprint { name: "zclaw_saas" })
26.961644800s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="role_test"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas)/Check { test: true }/TargetInner { kind: "test", name: "role_test", benched: false, ..: with_path("G:\\ZClaw_openfang\\crates\\zclaw-saas\\tests\\role_test.rs", Edition2021) }
26.961691600s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="role_test"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDepFingerprint { name: "zclaw_saas" })
26.973427300s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="telemetry_test"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas)/Check { test: true }/TargetInner { kind: "test", name: "telemetry_test", benched: false, ..: with_path("G:\\ZClaw_openfang\\crates\\zclaw-saas\\tests\\telemetry_test.rs", Edition2021) }
26.973480100s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="telemetry_test"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDepFingerprint { name: "zclaw_saas" })
0.891938300s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="zclaw_saas"}: cargo::core::compiler::fingerprint: stale: changed "G:\\ZClaw_openfang\\crates\\zclaw-saas\\src\\model_config\\service.rs"
0.891979400s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="zclaw_saas"}: cargo::core::compiler::fingerprint: (vs) "G:\\ZClaw_openfang\\target\\debug\\.fingerprint\\zclaw-saas-72a9fbf3f830e69e\\dep-lib-zclaw_saas"
0.891987700s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="zclaw_saas"}: cargo::core::compiler::fingerprint: FileTime { seconds: 13419244492, nanos: 580024000 } < FileTime { seconds: 13419244569, nanos: 751721700 }
0.892264600s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="zclaw_saas"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas)/Check { test: false }/TargetInner { name_inferred: true, ..: lib_target("zclaw_saas", ["lib"], "G:\\ZClaw_openfang\\crates\\zclaw-saas\\src\\lib.rs", Edition2021) }
0.892299500s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="zclaw_saas"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleItem(ChangedFile { reference: "G:\\ZClaw_openfang\\target\\debug\\.fingerprint\\zclaw-saas-72a9fbf3f830e69e\\dep-lib-zclaw_saas", reference_mtime: FileTime { seconds: 13419244492, nanos: 580024000 }, stale: "G:\\ZClaw_openfang\\crates\\zclaw-saas\\src\\model_config\\service.rs", stale_mtime: FileTime { seconds: 13419244569, nanos: 751721700 } }))
0.903620200s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="zclaw_saas"}: cargo::core::compiler::fingerprint: stale: changed "G:\\ZClaw_openfang\\crates\\zclaw-saas\\src\\prompt\\service.rs"
0.903645300s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="zclaw_saas"}: cargo::core::compiler::fingerprint: (vs) "G:\\ZClaw_openfang\\target\\debug\\.fingerprint\\zclaw-saas-bdfcbb25abc6c767\\dep-test-lib-zclaw_saas"
0.903652200s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="zclaw_saas"}: cargo::core::compiler::fingerprint: FileTime { seconds: 13419244492, nanos: 590547500 } < FileTime { seconds: 13419244749, nanos: 30511300 }
0.903823000s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="zclaw_saas"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas)/Check { test: true }/TargetInner { name_inferred: true, ..: lib_target("zclaw_saas", ["lib"], "G:\\ZClaw_openfang\\crates\\zclaw-saas\\src\\lib.rs", Edition2021) }
0.903848300s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="zclaw_saas"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleItem(ChangedFile { reference: "G:\\ZClaw_openfang\\target\\debug\\.fingerprint\\zclaw-saas-bdfcbb25abc6c767\\dep-test-lib-zclaw_saas", reference_mtime: FileTime { seconds: 13419244492, nanos: 590547500 }, stale: "G:\\ZClaw_openfang\\crates\\zclaw-saas\\src\\prompt\\service.rs", stale_mtime: FileTime { seconds: 13419244749, nanos: 30511300 } }))
0.907258400s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="zclaw-saas"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas)/Check { test: false }/TargetInner { name: "zclaw-saas", doc: true, ..: with_path("G:\\ZClaw_openfang\\crates\\zclaw-saas\\src\\main.rs", Edition2021) }
0.907293400s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="zclaw-saas"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDepFingerprint { name: "zclaw_saas" })
0.909141400s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="zclaw-saas"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas)/Check { test: true }/TargetInner { name: "zclaw-saas", doc: true, ..: with_path("G:\\ZClaw_openfang\\crates\\zclaw-saas\\src\\main.rs", Edition2021) }
0.909162100s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="zclaw-saas"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDepFingerprint { name: "zclaw_saas" })
0.910736700s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="account_test"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas)/Check { test: true }/TargetInner { kind: "test", name: "account_test", benched: false, ..: with_path("G:\\ZClaw_openfang\\crates\\zclaw-saas\\tests\\account_test.rs", Edition2021) }
0.910755600s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="account_test"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDepFingerprint { name: "zclaw_saas" })
0.912894200s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="agent_template_test"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas)/Check { test: true }/TargetInner { kind: "test", name: "agent_template_test", benched: false, ..: with_path("G:\\ZClaw_openfang\\crates\\zclaw-saas\\tests\\agent_template_test.rs", Edition2021) }
0.912920100s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="agent_template_test"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDepFingerprint { name: "zclaw_saas" })
0.914486400s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="auth_test"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas)/Check { test: true }/TargetInner { kind: "test", name: "auth_test", benched: false, ..: with_path("G:\\ZClaw_openfang\\crates\\zclaw-saas\\tests\\auth_test.rs", Edition2021) }
0.914504700s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="auth_test"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDepFingerprint { name: "zclaw_saas" })
0.917159500s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="migration_test"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas)/Check { test: true }/TargetInner { kind: "test", name: "migration_test", benched: false, ..: with_path("G:\\ZClaw_openfang\\crates\\zclaw-saas\\tests\\migration_test.rs", Edition2021) }
0.917191300s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="migration_test"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDepFingerprint { name: "zclaw_saas" })
0.919469500s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="model_config_test"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas)/Check { test: true }/TargetInner { kind: "test", name: "model_config_test", benched: false, ..: with_path("G:\\ZClaw_openfang\\crates\\zclaw-saas\\tests\\model_config_test.rs", Edition2021) }
0.919497700s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="model_config_test"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDepFingerprint { name: "zclaw_saas" })
0.922689500s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="prompt_test"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas)/Check { test: true }/TargetInner { kind: "test", name: "prompt_test", benched: false, ..: with_path("G:\\ZClaw_openfang\\crates\\zclaw-saas\\tests\\prompt_test.rs", Edition2021) }
0.922771400s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="prompt_test"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDepFingerprint { name: "zclaw_saas" })
0.926483800s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="relay_test"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas)/Check { test: true }/TargetInner { kind: "test", name: "relay_test", benched: false, ..: with_path("G:\\ZClaw_openfang\\crates\\zclaw-saas\\tests\\relay_test.rs", Edition2021) }
0.926528800s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="relay_test"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDepFingerprint { name: "zclaw_saas" })
0.929864100s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="role_test"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas)/Check { test: true }/TargetInner { kind: "test", name: "role_test", benched: false, ..: with_path("G:\\ZClaw_openfang\\crates\\zclaw-saas\\tests\\role_test.rs", Edition2021) }
0.929898400s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="role_test"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDepFingerprint { name: "zclaw_saas" })
0.931901900s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="telemetry_test"}: cargo::core::compiler::fingerprint: fingerprint dirty for zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas)/Check { test: true }/TargetInner { kind: "test", name: "telemetry_test", benched: false, ..: with_path("G:\\ZClaw_openfang\\crates\\zclaw-saas\\tests\\telemetry_test.rs", Edition2021) }
0.931925900s INFO prepare_target{force=false package_id=zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas) target="telemetry_test"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDepFingerprint { name: "zclaw_saas" })
Checking zclaw-saas v0.1.0 (G:\ZClaw_openfang\crates\zclaw-saas)
Checking zclaw-kernel v0.1.0 (G:\ZClaw_openfang\crates\zclaw-kernel)
Checking zclaw-memory v0.1.0 (G:\ZClaw_openfang\crates\zclaw-memory)
error: could not compile `zclaw-memory` (lib test) due to 1 previous error
warning: build failed, waiting for other jobs to finish...
Checking zclaw-pipeline v0.1.0 (G:\ZClaw_openfang\crates\zclaw-pipeline)
Checking desktop v0.1.0 (G:\ZClaw_openfang\desktop\src-tauri)
error: could not compile `zclaw-saas` (lib) due to 8 previous errors; 8 warnings emitted
error: could not compile `zclaw-saas` (lib test) due to 8 previous errors; 12 warnings emitted

File diff suppressed because one or more lines are too long