Initial CodeCraft: web-based coding harness for AI models
Rust backend (axum/tokio): agent loop with streaming, tools (fs, bash, web_fetch, todo, ask_user, subagents, skills, craft snapshots/compare), permissions with modes and rules, Anthropic + OpenAI-compatible providers, MCP client (stdio/HTTP, elicitation), SQLite sessions, usage statistics, compaction, commented TOML config editable via toml_edit. React PWA: chat with live streaming, permission/question cards, modes (build/plan/craft), settings for providers/models/prices/MCP/permissions, raw config editor, statistics dashboard, i18n (en/de). Docker setup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
f3a2fe3816
62 files changed
+21835
No files matched your search
@@ -0,0 +1,317 @@
|
||||
//! SQLite persistence for sessions and messages.
|
||||
|
||||
use crate::types::{Message, Session, now_ms};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use anyhow::Result;
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub struct Store {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
let conn = Connection::open(path)?;
|
||||
conn.execute_batch(
|
||||
"PRAGMA journal_mode=WAL;
|
||||
PRAGMA foreign_keys=ON;
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
seq INTEGER NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS messages_session ON messages(session_id, seq);
|
||||
-- One row per model request. Kept when sessions are deleted (statistics).
|
||||
CREATE TABLE IF NOT EXISTS usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
root_session_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
agent TEXT NOT NULL,
|
||||
input_tokens INTEGER NOT NULL,
|
||||
output_tokens INTEGER NOT NULL,
|
||||
cache_read_tokens INTEGER NOT NULL,
|
||||
cache_write_tokens INTEGER NOT NULL,
|
||||
ttft_ms INTEGER NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
cost REAL NOT NULL,
|
||||
error INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS usage_ts ON usage(ts);
|
||||
CREATE INDEX IF NOT EXISTS usage_root ON usage(root_session_id);",
|
||||
)?;
|
||||
Ok(Self { conn: Mutex::new(conn) })
|
||||
}
|
||||
|
||||
pub fn save_session(&self, s: &Session) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, data, parent_id, updated_at) VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at",
|
||||
params![s.id, serde_json::to_string(s)?, s.parent_id, s.updated_at],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_session(&self, id: &str) -> Result<Option<Session>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let data: Option<String> = conn
|
||||
.query_row("SELECT data FROM sessions WHERE id = ?1", [id], |r| r.get(0))
|
||||
.optional()?;
|
||||
Ok(data.map(|d| serde_json::from_str(&d)).transpose()?)
|
||||
}
|
||||
|
||||
/// Top-level sessions (subagent sessions are reachable through their parent).
|
||||
pub fn list_sessions(&self) -> Result<Vec<Session>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT data FROM sessions WHERE parent_id IS NULL ORDER BY updated_at DESC LIMIT 500",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
out.push(serde_json::from_str(&row?)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn delete_session(&self, id: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
// Child sessions (subagents) go with their parent.
|
||||
conn.execute("DELETE FROM sessions WHERE parent_id = ?1", [id])?;
|
||||
conn.execute("DELETE FROM sessions WHERE id = ?1", [id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_message(&self, session_id: &str, m: &Message) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO messages (id, session_id, seq, data) VALUES (?1, ?2,
|
||||
(SELECT COALESCE(MAX(seq), 0) + 1 FROM messages WHERE session_id = ?2), ?3)
|
||||
ON CONFLICT(id) DO UPDATE SET data = excluded.data",
|
||||
params![m.id, session_id, serde_json::to_string(m)?],
|
||||
)?;
|
||||
conn.execute(
|
||||
"UPDATE sessions SET updated_at = ?2 WHERE id = ?1",
|
||||
params![session_id, now_ms()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn messages(&self, session_id: &str) -> Result<Vec<Message>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT data FROM messages WHERE session_id = ?1 ORDER BY seq")?;
|
||||
let rows = stmt.query_map([session_id], |r| r.get::<_, String>(0))?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
out.push(serde_json::from_str(&row?)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Remove all messages after (and including) the given one – used for "edit & resend".
|
||||
pub fn truncate_from(&self, session_id: &str, message_id: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"DELETE FROM messages WHERE session_id = ?1 AND seq >=
|
||||
(SELECT seq FROM messages WHERE id = ?2 AND session_id = ?1)",
|
||||
params![session_id, message_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// One model request, as recorded for statistics.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct UsageRecord {
|
||||
pub ts: i64,
|
||||
pub session_id: String,
|
||||
pub root_session_id: String,
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub agent: String,
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cache_read_tokens: u64,
|
||||
pub cache_write_tokens: u64,
|
||||
/// Time to first token (prefill + queueing), milliseconds.
|
||||
pub ttft_ms: u64,
|
||||
pub duration_ms: u64,
|
||||
pub cost: f64,
|
||||
pub error: bool,
|
||||
}
|
||||
|
||||
/// Aggregation SQL shared by all statistics queries.
|
||||
const AGG: &str = "COUNT(*) AS requests,
|
||||
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
|
||||
COALESCE(SUM(cache_read_tokens),0), COALESCE(SUM(cache_write_tokens),0),
|
||||
COALESCE(SUM(cost),0.0), COALESCE(SUM(ttft_ms),0), COALESCE(SUM(duration_ms),0),
|
||||
COALESCE(SUM(error),0)";
|
||||
|
||||
fn agg_row(r: &rusqlite::Row, offset: usize) -> rusqlite::Result<Value> {
|
||||
let requests: i64 = r.get(offset)?;
|
||||
let input: i64 = r.get(offset + 1)?;
|
||||
let output: i64 = r.get(offset + 2)?;
|
||||
let cache_read: i64 = r.get(offset + 3)?;
|
||||
let cache_write: i64 = r.get(offset + 4)?;
|
||||
let cost: f64 = r.get(offset + 5)?;
|
||||
let ttft: i64 = r.get(offset + 6)?;
|
||||
let duration: i64 = r.get(offset + 7)?;
|
||||
let errors: i64 = r.get(offset + 8)?;
|
||||
let gen_ms = (duration - ttft).max(0);
|
||||
let prompt = input + cache_read + cache_write;
|
||||
Ok(json!({
|
||||
"requests": requests,
|
||||
"input_tokens": input,
|
||||
"output_tokens": output,
|
||||
"cache_read_tokens": cache_read,
|
||||
"cache_write_tokens": cache_write,
|
||||
"cost": cost,
|
||||
"errors": errors,
|
||||
"avg_ttft_ms": if requests > 0 { ttft / requests } else { 0 },
|
||||
// Output speed: tokens generated per second after the first token.
|
||||
"output_tps": if gen_ms > 0 { output as f64 * 1000.0 / gen_ms as f64 } else { 0.0 },
|
||||
// Prefill speed: prompt tokens processed per second until the first token.
|
||||
"prefill_tps": if ttft > 0 { prompt as f64 * 1000.0 / ttft as f64 } else { 0.0 },
|
||||
"cache_hit_rate": if prompt > 0 { cache_read as f64 / prompt as f64 } else { 0.0 },
|
||||
"duration_ms": duration,
|
||||
}))
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn add_usage(&self, u: &UsageRecord) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO usage (ts, session_id, root_session_id, provider, model, agent,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
||||
ttft_ms, duration_ms, cost, error)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)",
|
||||
params![
|
||||
u.ts, u.session_id, u.root_session_id, u.provider, u.model, u.agent,
|
||||
u.input_tokens as i64, u.output_tokens as i64, u.cache_read_tokens as i64,
|
||||
u.cache_write_tokens as i64, u.ttft_ms as i64, u.duration_ms as i64, u.cost, u.error as i64
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Statistics for one chat (including its subagents), totals and per model.
|
||||
pub fn session_stats(&self, root_session_id: &str) -> Result<Value> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let total = conn.query_row(
|
||||
&format!("SELECT {AGG} FROM usage WHERE root_session_id = ?1"),
|
||||
[root_session_id],
|
||||
|r| agg_row(r, 0),
|
||||
)?;
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT provider || '/' || model, {AGG} FROM usage WHERE root_session_id = ?1
|
||||
GROUP BY 1 ORDER BY 1"
|
||||
))?;
|
||||
let by_model = stmt
|
||||
.query_map([root_session_id], |r| {
|
||||
let mut v = agg_row(r, 1)?;
|
||||
v["model"] = json!(r.get::<_, String>(0)?);
|
||||
Ok(v)
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(json!({"total": total, "by_model": by_model}))
|
||||
}
|
||||
|
||||
/// Global statistics in [from, to) (ms). `tz_offset_min` shifts day/hour buckets
|
||||
/// into the viewer's local time.
|
||||
pub fn global_stats(&self, from: i64, to: i64, tz_offset_min: i64) -> Result<Value> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let shift = tz_offset_min * 60_000;
|
||||
let total = conn.query_row(
|
||||
&format!("SELECT {AGG} FROM usage WHERE ts >= ?1 AND ts < ?2"),
|
||||
params![from, to],
|
||||
|r| agg_row(r, 0),
|
||||
)?;
|
||||
|
||||
let grouped = |key_sql: &str, key_name: &str| -> Result<Vec<Value>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {key_sql}, {AGG} FROM usage WHERE ts >= ?1 AND ts < ?2 AND ?3 = ?3 GROUP BY 1 ORDER BY 1"
|
||||
))?;
|
||||
let rows = stmt
|
||||
.query_map(params![from, to, shift], |r| {
|
||||
let mut v = agg_row(r, 1)?;
|
||||
v[key_name] = match r.get_ref(0)? {
|
||||
rusqlite::types::ValueRef::Integer(i) => json!(i),
|
||||
rusqlite::types::ValueRef::Text(t) => json!(String::from_utf8_lossy(t)),
|
||||
_ => Value::Null,
|
||||
};
|
||||
Ok(v)
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
};
|
||||
|
||||
let day_sql = "strftime('%Y-%m-%d', (ts + ?3) / 1000, 'unixepoch')";
|
||||
let by_day = grouped(day_sql, "day")?;
|
||||
let by_model = grouped("provider || '/' || model", "model")?;
|
||||
let by_provider = grouped("provider", "provider")?;
|
||||
let by_agent = grouped("agent", "agent")?;
|
||||
// Heatmap: weekday (0 = Sunday) x hour of day.
|
||||
let heatmap = grouped(
|
||||
"CAST(strftime('%w', (ts + ?3) / 1000, 'unixepoch') AS INTEGER) * 24 + \
|
||||
CAST(strftime('%H', (ts + ?3) / 1000, 'unixepoch') AS INTEGER)",
|
||||
"slot",
|
||||
)?;
|
||||
let by_day_model = {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {day_sql}, provider || '/' || model, SUM(cost),
|
||||
SUM(input_tokens + cache_read_tokens + cache_write_tokens), SUM(output_tokens)
|
||||
FROM usage WHERE ts >= ?1 AND ts < ?2 GROUP BY 1, 2 ORDER BY 1"
|
||||
))?;
|
||||
stmt.query_map(params![from, to, shift], |r| {
|
||||
Ok(json!({
|
||||
"day": r.get::<_, String>(0)?,
|
||||
"model": r.get::<_, String>(1)?,
|
||||
"cost": r.get::<_, f64>(2)?,
|
||||
"input_tokens": r.get::<_, i64>(3)?,
|
||||
"output_tokens": r.get::<_, i64>(4)?,
|
||||
}))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
// Most expensive chats in the range.
|
||||
let top_sessions = {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT u.root_session_id, COALESCE(json_extract(s.data, '$.title'), '(deleted)'), {AGG}
|
||||
FROM usage u LEFT JOIN sessions s ON s.id = u.root_session_id
|
||||
WHERE u.ts >= ?1 AND u.ts < ?2
|
||||
GROUP BY 1 ORDER BY SUM(u.cost) DESC, COUNT(*) DESC LIMIT 15"
|
||||
))?;
|
||||
stmt.query_map(params![from, to], |r| {
|
||||
let mut v = agg_row(r, 2)?;
|
||||
v["session_id"] = json!(r.get::<_, String>(0)?);
|
||||
v["title"] = json!(r.get::<_, String>(1)?);
|
||||
Ok(v)
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
Ok(json!({
|
||||
"total": total,
|
||||
"by_day": by_day,
|
||||
"by_day_model": by_day_model,
|
||||
"by_model": by_model,
|
||||
"by_provider": by_provider,
|
||||
"by_agent": by_agent,
|
||||
"heatmap": heatmap,
|
||||
"top_sessions": top_sessions,
|
||||
}))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user