Skip to content
Open
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
201 changes: 201 additions & 0 deletions crates/wb-switch-core/src/modules/automations.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
//! 带走定时任务(L3 归属层,DB):把 `automations` 与 `automation_delivery_outbox`
//! 的 `owner_user_id` 对齐到目标账号。
//!
//! 独立成模块(2026-09-19 主人定):上游发 PR 单独走一个,不与「同步设置与文件」
//! (`align.rs` 的 L4/L5)捆绑。与 align 的关系:
//! - `align_data`(真实执行/预览)在 `align_automations` 开关下调
//! [`align_automations_owner_in_db`];
//! - 独立入口 [`align_automations_owner`] 由 `/api/automations/align` 使用
//! (不切号也能对齐,需先完全退出 WorkBuddy)。

use serde_json::{json, Value};
use std::path::Path;

use crate::modules::config::{backup_dir, now_ms, utc_iso};
use crate::modules::session::{backup_workbuddy_db, open_db, table_exists, workbuddy_db_path};
use crate::modules::variant::WbVariant;

/// 把未删除自动化的 owner 对齐到目标账号(含备份)。db 不存在返回 None。
pub fn align_automations_owner(target_uid: &str) -> Option<Value> {
let db = workbuddy_db_path(WbVariant::Cn);
if !db.is_file() {
return None;
}
let backup = backup_workbuddy_db(WbVariant::Cn, &backup_dir().join("automations").join(utc_iso()))
.map(|p| p.to_string_lossy().to_string());
let (automations, outbox) =
align_automations_owner_in_db(&db, target_uid, false).unwrap_or((0, 0));
Some(json!({
"targetUid": target_uid,
"automationsUpdated": automations,
"outboxUpdated": outbox,
"backup": backup,
}))
}

/// 低层:对齐 automations + outbox 的 owner,返回 (automations 行数, outbox 行数)。
pub(crate) fn align_automations_owner_in_db(
db_path: &Path,
target_uid: &str,
dry_run: bool,
) -> Result<(usize, usize), String> {
if !db_path.is_file() {
return Ok((0, 0));
}
let Some(conn) = open_db(db_path, false) else {
return Ok((0, 0));
};

let mut automations_updated = 0usize;
if table_exists(&conn, "automations") {
let n: i64 = conn
.query_row(
"SELECT COUNT(*) FROM automations \
WHERE deleted_at IS NULL AND (owner_user_id IS NULL OR owner_user_id != ?1)",
rusqlite::params![target_uid],
|r| r.get(0),
)
.unwrap_or(0);
if n > 0 && !dry_run {
conn.execute(
"UPDATE automations SET owner_user_id = ?1, updated_at = ?2 \
WHERE deleted_at IS NULL AND (owner_user_id IS NULL OR owner_user_id != ?1)",
rusqlite::params![target_uid, now_ms()],
)
.map_err(|e| e.to_string())?;
}
automations_updated = n as usize;
}

let mut outbox_updated = 0usize;
if table_exists(&conn, "automation_delivery_outbox") {
let n: i64 = conn
.query_row(
"SELECT COUNT(*) FROM automation_delivery_outbox \
WHERE finished_at IS NULL AND (owner_user_id IS NULL OR owner_user_id != ?1)",
rusqlite::params![target_uid],
|r| r.get(0),
)
.unwrap_or(0);
if n > 0 && !dry_run {
conn.execute(
"UPDATE automation_delivery_outbox SET owner_user_id = ?1, updated_at = ?2 \
WHERE finished_at IS NULL AND (owner_user_id IS NULL OR owner_user_id != ?1)",
rusqlite::params![target_uid, now_ms()],
)
.map_err(|e| e.to_string())?;
}
outbox_updated = n as usize;
}

Ok((automations_updated, outbox_updated))
}

#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
use std::path::PathBuf;

fn temp_db(name: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"wb_switch_test_{}_{name}.db",
uuid::Uuid::new_v4().simple()
))
}

fn setup(db: &Path) {
let conn = Connection::open(db).unwrap();
conn.execute_batch(
"CREATE TABLE automations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
owner_user_id TEXT,
status TEXT NOT NULL DEFAULT 'ACTIVE',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER
);
CREATE TABLE automation_delivery_outbox (
id TEXT PRIMARY KEY,
automation_id TEXT NOT NULL,
owner_user_id TEXT,
status TEXT NOT NULL,
finished_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
INSERT INTO automations (id, name, owner_user_id, created_at, updated_at, deleted_at)
VALUES
('a-1', '旧账号的自动化', 'uid-a', 1, 1, NULL),
('a-2', '已删除的自动化', 'uid-a', 1, 1, 100),
('a-3', '已是目标账号', 'uid-b', 1, 1, NULL),
('a-4', '无归属 legacy', NULL, 1, 1, NULL);
INSERT INTO automation_delivery_outbox
(id, automation_id, owner_user_id, status, finished_at, created_at, updated_at)
VALUES
('o-1', 'a-1', 'uid-a', 'pending', NULL, 1, 1),
('o-2', 'a-1', 'uid-a', 'finished', 999, 1, 1);",
)
.unwrap();
}

#[test]
fn align_automations_moves_live_rows_to_target() {
let db = temp_db("align_auto");
setup(&db);

let (n, o) = align_automations_owner_in_db(&db, "uid-b", false).unwrap();
assert_eq!(n, 2, "a-1(owner 不同)/a-4(NULL) 两行;a-2 已软删、a-3 已是目标,均不动");
assert_eq!(o, 1, "只有未投递完成的 o-1 会被对齐");

let conn = Connection::open(&db).unwrap();
let get = |id: &str| -> Option<String> {
conn.query_row(
"SELECT owner_user_id FROM automations WHERE id = ?1",
[id],
|r| r.get(0),
)
.ok()
};
assert_eq!(get("a-1").as_deref(), Some("uid-b"));
assert_eq!(get("a-2").as_deref(), Some("uid-a"), "软删行保留原归属");
assert_eq!(get("a-3").as_deref(), Some("uid-b"));
assert_eq!(get("a-4").as_deref(), Some("uid-b"), "legacy 无归属行一并接管");

let outbox_owner: String = conn
.query_row(
"SELECT owner_user_id FROM automation_delivery_outbox WHERE id = 'o-2'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(outbox_owner, "uid-a", "已完成的投递行不动");
}

#[test]
fn align_automations_dry_run_does_not_write() {
let db = temp_db("align_auto_dry");
setup(&db);
let (n, _o) = align_automations_owner_in_db(&db, "uid-b", true).unwrap();
assert_eq!(n, 2, "dry-run 也要统计计划行数");
let owner: String = Connection::open(&db)
.unwrap()
.query_row(
"SELECT owner_user_id FROM automations WHERE id='a-1'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(owner, "uid-a", "dry-run 不落盘");
}

#[test]
fn align_missing_db_is_noop() {
let db = temp_db("missing");
assert_eq!(
align_automations_owner_in_db(&db, "uid-b", false).unwrap(),
(0, 0)
);
}
}
2 changes: 2 additions & 0 deletions crates/wb-switch-core/src/modules/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod account;
pub mod automations;
pub mod auth_file;
pub mod checkin;
pub mod codebuddy_cli;
Expand All @@ -11,6 +12,7 @@ pub mod export_import;
pub mod limits;
pub mod oauth;
pub mod official_usage;
pub mod oplog;
pub mod process;
pub mod rate_limit_events;
pub mod rate_limit_hook;
Expand Down
104 changes: 104 additions & 0 deletions crates/wb-switch-core/src/modules/oplog.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
//! 切号操作留痕(本地专属模块,上游无此文件,改动零合并冲突)。
//!
//! 目的:切号 / 数据对齐的结果此前只在 UI 当次展示,事后无法回溯「何时切到谁、
//! 勾了哪些对齐项、各层改了多少」。这里把结果追加到
//! `~/.wb-switch/switch_logs.json`,仅留痕,不阻断切号流程。
//!
//! 与 wb_multi_sync 的 `logs/sync-<日期>.log` 对应,但按条存 JSON 便于后续在
//! 设置页做展示(UI 未接入时可直接读文件)。

use crate::modules::config::{atomic_write, now_ms, store_dir, utc_iso};
use serde_json::{json, Value};
use std::path::PathBuf;

/// 保留最近多少条(与自动轮换日志同量级)。
pub const SWITCH_LOG_MAX_RECORDS: usize = 200;

pub fn switch_logs_file() -> PathBuf {
store_dir().join("switch_logs.json")
}

/// 读取全部切号日志(保持写入顺序,最旧在前)。
pub fn load_switch_logs() -> Vec<Value> {
let Ok(text) = std::fs::read_to_string(switch_logs_file()) else {
return vec![];
};
serde_json::from_str::<Vec<Value>>(&text).unwrap_or_default()
}

/// 保存切号日志(保留最近 N 条,保持插入顺序)。
pub fn save_switch_logs(logs: &[Value]) -> std::io::Result<()> {
let mut kept: Vec<Value> = logs.to_vec();
if kept.len() > SWITCH_LOG_MAX_RECORDS {
kept.drain(..kept.len() - SWITCH_LOG_MAX_RECORDS);
}
std::fs::create_dir_all(store_dir())?;
let content = serde_json::to_string_pretty(&kept).unwrap_or_default();
atomic_write(&switch_logs_file(), &content)
}

/// 追加一条切号日志(写失败不抛错,绝不阻断切号)。
pub fn add_switch_log(entry: &Value) {
let mut logs = load_switch_logs();
logs.push(entry.clone());
let _ = save_switch_logs(&logs);
}

/// 组装一条切号日志记录。
///
/// - `action`:`switch`(正常切换)/ `dry-run`(预览)/ `error`(失败)
/// - `from` / `to`:源账号 uid、目标账号 uid
/// - `options`:本次勾选项(复制会话数、三个对齐开关)
/// - `result`:成功时的分段结果,或失败原因
pub fn switch_log_entry(
action: &str,
from_uid: Option<&str>,
to_uid: &str,
options: &Value,
result: &Value,
) -> Value {
json!({
"ts": now_ms(),
"at": utc_iso(),
"action": action,
"from": from_uid,
"to": to_uid,
"options": options,
"result": result,
})
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn entry_carries_action_origin_and_options() {
let entry = switch_log_entry(
"switch",
Some("u-from"),
"u-to",
&json!({"copySessions": 2, "alignAutomations": true}),
&json!({"ok": true}),
);
assert_eq!(entry["action"], json!("switch"));
assert_eq!(entry["from"], json!("u-from"));
assert_eq!(entry["to"], json!("u-to"));
assert_eq!(entry["options"]["copySessions"], json!(2));
assert!(entry["ts"].as_i64().unwrap_or(0) > 0);
assert!(entry["at"].as_str().is_some_and(|s| !s.is_empty()));
}

#[test]
fn save_keeps_only_latest_records_and_preserves_order() {
let mut logs: Vec<Value> = (0..SWITCH_LOG_MAX_RECORDS + 5)
.map(|i| json!({ "seq": i }))
.collect();
if logs.len() > SWITCH_LOG_MAX_RECORDS {
logs.drain(..logs.len() - SWITCH_LOG_MAX_RECORDS);
}
assert_eq!(logs.len(), SWITCH_LOG_MAX_RECORDS);
assert_eq!(logs[0]["seq"], json!(5));
assert_eq!(logs[logs.len() - 1]["seq"], json!(SWITCH_LOG_MAX_RECORDS + 4));
}
}
2 changes: 1 addition & 1 deletion crates/wb-switch-core/src/modules/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ fn find_project_jsonl(variant: WbVariant, cid: &str) -> Option<PathBuf> {
}

/// 备份 workbuddy.db(含 -wal/-shm),返回主库备份路径。对照 `backup_workbuddy_db`。
fn backup_workbuddy_db(variant: WbVariant, backup_root: &Path) -> Option<PathBuf> {
pub(crate) fn backup_workbuddy_db(variant: WbVariant, backup_root: &Path) -> Option<PathBuf> {
let db = workbuddy_db_path(variant);
if !db.is_file() {
return None;
Expand Down
Loading