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:
parent
38707ccd72
commit
bbbe494dae
|
|
@ -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"};
|
||||
|
|
|
|||
24
src/store.rs
24
src/store.rs
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue