SQLite layer: the audit trail becomes queryable history (P4, step 1)

- src/store.rs: bundled SQLite (WAL) in the data folder; every oplog record
  is mirrored into an audit table — turns, model calls, tools with their
  arguments and end statuses, queued messages, stops
- side-car by design: state.json stays the app's truth (one struct, written
  atomically); the mirror is best-effort — an audit that never breaks a turn,
  and a timeline that answers for 30 days, not just for the running turn
- /api/bots/<id>/audit now reads the trail (phone's 稽核 tab gains history)
- retention: rows older than KEEP_DAYS are deleted on open
- groundwork for memory/RAG (P6) on the same layer; state.json itself moves
  in step 2 with the memory tables (the app keeps reading State as a struct)
This commit is contained in:
AnyBot Dev 2026-11-09 18:02:56 +08:00
parent 38707ccd72
commit bbbe494dae
2 changed files with 17 additions and 9 deletions

View File

@ -1318,7 +1318,7 @@ function tabBody(b, d) {
if (S.tab === "audit") {
const a = S.audit[b.id];
if (a === undefined) { loadAudit(b.id); return `<div class="note" style="padding:14px 16px">載入中…</div>`; }
if (!a.turns.length) return `<div class="empty">現在沒有進行中的工作。稽核時間軸記的是**這回合**做了什麼:每次問模型、每個工具的參數與結果、你插入的話、停止。回合結束後只留最近的,聊天記錄本身才是長久的。</div>`;
if (!a.turns.length) return `<div class="empty">現在沒有進行中的工作。稽核時間軸記的是**這回合**做了什麼:每次問模型、每個工具的參數與結果、你插入的話、停止。這裡記的是最近 30 天的每一回合;更早的故事在聊天記錄裡。</div>`;
const OUT = {completed: "已結束", aborted: "被你停止", failed: "失敗"};
const ST = {done: "完成", running: "進行中…", waiting: "等核准", denied: "被拒絕", failed: "失敗"};
const TI = {computer: "zap", menu_click: "zap", fill_form: "zap", wait_for: "clock", run_cmd: "zap", web_fetch: "globe", browser: "globe"};

View File

@ -32,7 +32,11 @@ fn path() -> PathBuf {
/// Open (create if needed) the data folder's audit database. Failure means "no
/// history", never "no bot".
fn open() -> Option<Store> {
let c = Connection::open(path()).ok()?;
let p = path();
if let Some(d) = p.parent() {
let _ = std::fs::create_dir_all(d); // (the data folder may not exist yet on first run)
}
let c = Connection::open(p).ok()?;
c.pragma_update(None, "journal_mode", "WAL").ok()?;
c.pragma_update(None, "synchronous", "NORMAL").ok()?;
c.execute_batch(
@ -122,6 +126,7 @@ pub fn record(bot: u64, r: &Record) {
/// The phone's 稽核時間軸: finished turns stay here, so the page answers for
/// KEEP_DAYS, not just for the turn running now. Newest turn first; the last 20 turns.
pub fn turns(bot: u64) -> serde_json::Value {
let bot = bot as i64;
let Some(s) = store() else { return serde_json::json!({"turns": []}) };
let Ok(c) = s.conn.lock() else { return serde_json::json!({"turns": []}) };
let cutoff = crate::model::now_ms() - KEEP_DAYS * 86_400_000;
@ -208,18 +213,21 @@ mod tests {
#[test]
fn the_trail_keeps_the_story_after_the_turn_log_cleans_up() {
// (Tests share the temp dir of this process; one database, one bot id.)
// (Tests share the temp dir of this process; one database, one bot id.
// Times ride on "now" — the trail prunes anything older than a month.)
let bot = 4242;
record(bot, &Record::OperationStarted { op: "op1".into(), at: 100, source_leaf: 0, prompt: Some(Prompt { at: 99, text: "幫我叫車".into() }) });
let t0 = crate::model::now_ms() - 60_000;
let at = |d| t0 + d;
record(bot, &Record::OperationStarted { op: "op1".into(), at: at(0), source_leaf: 0, prompt: Some(Prompt { at: at(0), text: "幫我叫車".into() }) });
record(bot, &Record::StepAttempt { op: "op1".into(), step: "assistant".into(), attempt: 1 });
record(bot, &Record::StepAttempt { op: "op1".into(), step: "assistant".into(), attempt: 2 });
record(bot, &Record::ToolStarted { op: "op1".into(), call_id: "c1".into(), name: "computer".into(), args: "{\"action\":\"click\"}".into(), replay: Replay::Never, at: 110 });
record(bot, &Record::ToolFinished { op: "op1".into(), call_id: "c1".into(), status: "done".into(), at: 120 });
record(bot, &Record::QueueEnqueued { op: Some("op1".into()), id: "q1".into(), text: "快一點".into(), at: 111 });
record(bot, &Record::ToolStarted { op: "op1".into(), call_id: "c1".into(), name: "computer".into(), args: "{\"action\":\"click\"}".into(), replay: Replay::Never, at: at(10) });
record(bot, &Record::ToolFinished { op: "op1".into(), call_id: "c1".into(), status: "done".into(), at: at(20) });
record(bot, &Record::QueueEnqueued { op: Some("op1".into()), id: "q1".into(), text: "快一點".into(), at: at(11) });
record(bot, &Record::QueueConsumed { id: "q1".into() });
record(bot, &Record::OperationFinished { op: "op1".into(), outcome: Outcome::Completed });
record(bot, &Record::OperationStarted { op: "op2".into(), at: 200, source_leaf: 100, prompt: None });
record(bot, &Record::ToolStarted { op: "op2".into(), call_id: "c9".into(), name: "run_cmd".into(), args: "{}".into(), replay: Replay::Safe, at: 210 });
record(bot, &Record::OperationStarted { op: "op2".into(), at: at(100), source_leaf: 100, prompt: None });
record(bot, &Record::ToolStarted { op: "op2".into(), call_id: "c9".into(), name: "run_cmd".into(), args: "{}".into(), replay: Replay::Safe, at: at(110) });
let v = turns(bot);
let ts = v["turns"].as_array().unwrap();