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:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 14:39:24 +01:00
commit f3a2fe3816
62 files changed
+21835

No files matched your search

+317
View File
@@ -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,
}))
}
}