Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bin/twcore/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "ThinkWatch Lite 的本地 AI 网关引擎"
description = "Self-contained local AI gateway binary, and the engine behind ThinkWatch Lite"

[dependencies]
tw-config = { workspace = true }
Expand Down
2 changes: 1 addition & 1 deletion crates/tw-adopt/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "检测本机的 AI 客户端,接管与还原它们的配置"
description = "Detect local AI clients, take over their configuration, and restore it"

[dependencies]
serde = { workspace = true }
Expand Down
81 changes: 75 additions & 6 deletions crates/tw-adopt/src/foreign.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,27 +158,59 @@ fn write_atomic(real: &Path, text: &str, keep_mode: Option<u32>) -> Result<(), F
})
}

/// 备份目录:`~/.thinkwatch/backups/<毫秒时间戳>/`。
/// 备份目录:`~/.thinkwatch/backups/<毫秒时间戳>-<序号>/`。
///
/// 时间戳在目录名里,所以按名字排序就是时间序 —— 不必读 mtime(备份
/// 工具会把 mtime 全改成同一天)。
/// 工具会把 mtime 全改成同一天)。序号补零到固定宽度,同一毫秒里的
/// 几份也按先后排。
pub fn backup_root() -> PathBuf {
tw_config::default_dir().join("backups")
}

/// 同一毫秒里最多几份备份。补零宽度跟着它走,超了排序就不对了。
const BACKUP_SEQ_MAX: u32 = 9999;

fn backup_to(root: &Path, real: &Path, text: &str) -> Result<PathBuf, ForeignError> {
backup_at(root, real, text, now_ms())
}

/// 时间戳从外面传进来,测试才能稳定地造出「同一毫秒」。
fn backup_at(root: &Path, real: &Path, text: &str, ms: u64) -> Result<PathBuf, ForeignError> {
// 目录名里带上来源路径的形状,一眼能看出这是谁的备份
let flat = real
.to_string_lossy()
.trim_start_matches('/')
.replace('/', "%");
let dir = root.join(now_ms().to_string());
std::fs::create_dir_all(&dir).map_err(|source| ForeignError::Write {
path: dir.clone(),
std::fs::create_dir_all(root).map_err(|source| ForeignError::Write {
path: root.to_path_buf(),
source,
})?;
// **同一毫秒里连着接管两次,第二份备份不能盖掉第一份。**第二次备份
// 的内容里已经是我们写的密钥了;盖掉之后还原只能找回我们的密钥,
// 用户自己的那个就再也没有了。所以用 `create_dir`(不是 `_all`)
// 让文件系统原子地告诉我们目录是不是已经有了,有了就换下一个序号。
let mut seq = 0;
let dir = loop {
let dir = root.join(format!("{ms}-{seq:04}"));
match std::fs::create_dir(&dir) {
Ok(()) => break dir,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && seq < BACKUP_SEQ_MAX => {
seq += 1;
}
Err(source) => return Err(ForeignError::Write { path: dir, source }),
}
};
let file = dir.join(flat);
std::fs::write(&file, text).map_err(|source| ForeignError::Write {
// 目录是刚建的,按说不会有同名文件;万一有,也宁可失败不覆盖。
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&file)
.map_err(|source| ForeignError::Write {
path: file.clone(),
source,
})?;
std::io::Write::write_all(&mut f, text.as_bytes()).map_err(|source| ForeignError::Write {
path: file.clone(),
source,
})?;
Expand Down Expand Up @@ -395,6 +427,43 @@ mod tests {
assert!(!a.created);
}

#[test]
fn two_backups_in_the_same_millisecond_do_not_overwrite_each_other() {
// 连着接管两次时第二份备份里已经是我们的密钥;它要是盖掉第一份,
// 用户原来的密钥就没了,还原只能还原到我们这儿。
let (d, root) = dirs();
let p = d.path().join("c.json");
let first = backup_at(&root, &p, "sk-用户自己的", 1_700_000_000_000).unwrap();
let second = backup_at(&root, &p, "tw-我们写的", 1_700_000_000_000).unwrap();

assert_ne!(first, second);
assert_eq!(std::fs::read_to_string(&first).unwrap(), "sk-用户自己的");
assert_eq!(std::fs::read_to_string(&second).unwrap(), "tw-我们写的");

// 按名字排序仍然是时间序,跨毫秒也是
let third = backup_at(&root, &p, "后来的", 1_700_000_000_001).unwrap();
let mut names: Vec<_> = std::fs::read_dir(&root)
.unwrap()
.map(|e| e.unwrap().path())
.collect();
names.sort();
let parent = |f: &PathBuf| f.parent().unwrap().to_path_buf();
assert_eq!(names, vec![parent(&first), parent(&second), parent(&third)]);
}

#[test]
fn back_to_back_real_backups_all_survive() {
// 不注入时间戳,走真实时钟:一口气备份很多份,几乎必然撞在同一毫秒。
let (d, root) = dirs();
let p = d.path().join("c.json");
let files: Vec<_> = (0..50)
.map(|i| backup_to(&root, &p, &i.to_string()).unwrap())
.collect();
for (i, f) in files.iter().enumerate() {
assert_eq!(std::fs::read_to_string(f).unwrap(), i.to_string());
}
}

#[test]
fn creating_a_file_that_was_not_there_is_recorded_as_such() {
// 「原本没有这个文件」和「原本是空文件」不一样:还原时前者要把
Expand Down
2 changes: 1 addition & 1 deletion crates/tw-api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "控制面契约:请求/响应类型 + client"
description = "Control-plane contract: request and response types, plus a client"

[dependencies]
serde = { workspace = true }
Expand Down
2 changes: 1 addition & 1 deletion crates/tw-config/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "YAML schema、加载、校验"
description = "YAML config schema, loading, and validation"

[dependencies]
tw-engine = { workspace = true }
Expand Down
2 changes: 1 addition & 1 deletion crates/tw-control/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "控制面 API 服务端(unix socket)"
description = "Control-plane API server over a unix socket"

[dependencies]
tw-api = { workspace = true }
Expand Down
57 changes: 57 additions & 0 deletions crates/tw-control/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ pub fn router(state: ControlState) -> Router {
.route("/pricing/update/apply", post(update_apply))
.route("/config/rollback", post(config_rollback))
.route("/summary", get(summary))
.route("/summary/buckets", get(cost_buckets))
.route("/summary/by", get(cost_by))
.route("/history", get(history))
.route("/latency", get(latency))
.route("/latency/provider", get(latency_by_provider))
Expand Down Expand Up @@ -447,6 +449,45 @@ fn view(target: String, via: Option<String>, r: tw_gateway::L1Result) -> tw_api:
}
}

/// 按时间分桶的花费与请求数(概览的趋势图)。
///
/// 桶宽由调用方给:同一段数据,「今天每小时」和「最近 30 天每天」要的
/// 是两种桶,而在服务端写死一种,另一种就得再加一个端点。
///
/// **空桶不补。**SQL 的 GROUP BY 只产出有数据的桶,而要画多少格只有
/// 界面知道(它知道图有多宽)。在这里补的话,一个跨度很大、桶很窄的
/// 请求会让我们凭空造出几万个零。
async fn cost_buckets(
State(s): State<ControlState>,
axum::extract::Query(q): axum::extract::Query<BucketQuery>,
) -> Result<Json<Vec<tw_store::CostBucket>>, Fail> {
let (from, to) = q.win.range();
// 桶宽有下限,否则一个 `bucket_ms=1` 能让这条查询扫出几百万个分组。
let bucket = q.bucket_ms.unwrap_or(3_600_000).max(1_000);
let store = need_store(&s)?;
let g = store.lock().await;
let x = g
.db()
.cost_buckets(from, to, bucket)
.map_err(|e| fail(StatusCode::INTERNAL_SERVER_ERROR, e))?;
Ok(Json(x))
}

/// 按模型或上游分组的花费(钱花在哪儿)。
async fn cost_by(
State(s): State<ControlState>,
axum::extract::Query(q): axum::extract::Query<GroupQuery>,
) -> Result<Json<Vec<tw_store::CostGroup>>, Fail> {
let (from, to) = q.win.range();
let store = need_store(&s)?;
let g = store.lock().await;
let x = g
.db()
.cost_by(q.dim, from, to)
.map_err(|e| fail(StatusCode::INTERNAL_SERVER_ERROR, e))?;
Ok(Json(x))
}

/// 一段时间的汇总。不给参数就是「今天」。
async fn summary(
State(s): State<ControlState>,
Expand Down Expand Up @@ -1069,6 +1110,22 @@ struct Window {
to_ms: Option<i64>,
}

#[derive(serde::Deserialize)]
struct BucketQuery {
#[serde(flatten)]
win: Window,
bucket_ms: Option<i64>,
}

#[derive(serde::Deserialize)]
struct GroupQuery {
#[serde(flatten)]
win: Window,
/// **是枚举不是字符串。**它会决定 SQL 里的列名,用字符串就是一个
/// 注入口;写错的值在这里被 serde 直接拒掉,而不是拼进查询。
dim: tw_store::CostDim,
}

impl Window {
fn range(&self) -> (i64, i64) {
let now = now_ms();
Expand Down
2 changes: 1 addition & 1 deletion crates/tw-crypto/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "AES-256-GCM 信封加解密"
description = "AES-256-GCM envelope encryption and decryption"

[dependencies]
aes-gcm = { workspace = true }
Expand Down
2 changes: 1 addition & 1 deletion crates/tw-dialect/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "方言互转:Anthropic 与 OpenAI chat 之间的请求与响应翻译"
description = "Dialect translation of requests and responses between Anthropic and OpenAI chat"

[dependencies]
serde = { workspace = true }
Expand Down
2 changes: 1 addition & 1 deletion crates/tw-engine/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "路由规则引擎与策略组"
description = "Routing rule engine and policy groups"

[dependencies]
serde = { workspace = true }
Expand Down
2 changes: 1 addition & 1 deletion crates/tw-gateway/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "数据面 HTTP 服务"
description = "Data-plane HTTP server"

[dependencies]
tw-api = { workspace = true }
Expand Down
2 changes: 1 addition & 1 deletion crates/tw-observe/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "事件总线"
description = "Event bus"

[dependencies]
tw-api = { workspace = true }
Expand Down
2 changes: 1 addition & 1 deletion crates/tw-pricing/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "价目表:内置快照 + 用户覆盖,以及成本三态"
description = "Price table: a built-in snapshot, user overrides, and three-state cost (measured, estimated, unpriced)"

[dependencies]
serde = { workspace = true }
Expand Down
2 changes: 1 addition & 1 deletion crates/tw-protocol/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "SSE 分帧与方言间的内容提取"
description = "SSE framing and content extraction across API dialects"

[dependencies]
bytes = { workspace = true }
Expand Down
2 changes: 1 addition & 1 deletion crates/tw-provider/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "provider 抽象与各家实现"
description = "Provider abstraction and per-provider implementations"

[dependencies]
tw-types = { workspace = true }
Expand Down
2 changes: 1 addition & 1 deletion crates/tw-redact/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "出站脱敏与回显还原:把凭据换成占位符,再在响应里换回来"
description = "Outbound redaction: replace credentials with placeholders, and restore them when a response echoes them back"

[dependencies]
serde = { workspace = true }
Expand Down
2 changes: 1 addition & 1 deletion crates/tw-resil/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "重试、熔断、故障转移"
description = "Retries, circuit breaking, and failover"

[dependencies]
tw-types = { workspace = true }
Expand Down
2 changes: 1 addition & 1 deletion crates/tw-scan/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "客户端配置面的静态扫描:隐藏字符、提示注入、危险命令、过宽权限"
description = "Static scan of client configuration for hidden characters, prompt injection, dangerous commands, and overly broad permissions"

[dependencies]
serde = { workspace = true }
Expand Down
2 changes: 1 addition & 1 deletion crates/tw-secret/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "环境变量插值、exec 凭据、密钥打码"
description = "Environment variable interpolation, exec-sourced credentials, and secret masking"

[dependencies]
thiserror = { workspace = true }
2 changes: 1 addition & 1 deletion crates/tw-store/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "请求历史与运行时状态:SQLite + 文件系统"
description = "Request history and runtime state on SQLite and the filesystem"

[dependencies]
tw-api = { workspace = true }
Expand Down
Loading
Loading