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

+274
View File
@@ -0,0 +1,274 @@
//! Shared application state.
use crate::config::{self, Config};
use crate::mcp::McpManager;
use crate::provider::Registry;
use crate::store::Store;
use crate::types::{Event, Question, Session, now_ms};
use anyhow::{Result, anyhow};
use serde_json::{Map, Value};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, RwLock};
use tokio::sync::{broadcast, oneshot};
use tokio_util::sync::CancellationToken;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PermissionReply {
/// Allow this one call.
Once,
/// Allow this tool (or bash command prefix) for the rest of the session.
Always,
Reject,
}
struct PendingPermission {
event: Event,
tx: oneshot::Sender<(PermissionReply, Option<String>)>,
}
struct PendingQuestion {
event: Event,
tx: oneshot::Sender<Option<Map<String, Value>>>,
}
/// Per-session runtime state (not persisted).
#[derive(Default)]
pub struct Runtime {
pub cancel: Option<CancellationToken>,
pub read_files: HashSet<PathBuf>,
pub approved: HashSet<String>,
/// Prompts sent while the agent was busy; injected at the next step.
pub queue: Vec<String>,
}
pub struct App {
config_path: PathBuf,
config: RwLock<Arc<Config>>,
registry: RwLock<Arc<Registry>>,
pub store: Store,
events: broadcast::Sender<Event>,
pub mcp: Arc<McpManager>,
runtime: Mutex<HashMap<String, Runtime>>,
permissions: Mutex<HashMap<String, PendingPermission>>,
questions: Mutex<HashMap<String, PendingQuestion>>,
}
impl App {
pub async fn new(config_path: PathBuf, cfg: Config) -> Result<Arc<Self>> {
let data = cfg.data_dir();
std::fs::create_dir_all(&data)?;
let store = Store::open(&data.join("codecraft.db"))?;
let registry = Registry::build(&cfg).await;
let (events, _) = broadcast::channel(4096);
let app = Arc::new(Self {
config_path,
config: RwLock::new(Arc::new(cfg)),
registry: RwLock::new(Arc::new(registry)),
store,
events,
mcp: Arc::new(McpManager::default()),
runtime: Mutex::default(),
permissions: Mutex::default(),
questions: Mutex::default(),
});
app.clone().start_mcp();
Ok(app)
}
fn start_mcp(self: Arc<Self>) {
let servers = self.config().mcp.clone();
let mcp = self.mcp.clone();
let weak = Arc::downgrade(&self);
tokio::spawn(async move { mcp.start_all(weak, servers).await });
}
pub fn config(&self) -> Arc<Config> {
self.config.read().unwrap().clone()
}
pub fn registry(&self) -> Arc<Registry> {
self.registry.read().unwrap().clone()
}
pub fn workspace(&self) -> PathBuf {
let ws = self.config().workspace();
std::fs::canonicalize(&ws).unwrap_or(ws)
}
pub fn data_dir(&self) -> PathBuf {
self.config().data_dir()
}
pub fn config_text(&self) -> Result<String> {
Ok(std::fs::read_to_string(&self.config_path)?)
}
/// Validate, write and apply a new config text. Providers and MCP servers reload.
pub async fn save_config(self: &Arc<Self>, text: &str) -> Result<()> {
let cfg = Config::parse(text)?;
let tmp = self.config_path.with_extension("toml.tmp");
std::fs::write(&tmp, text)?;
std::fs::rename(&tmp, &self.config_path)?;
let registry = Registry::build(&cfg).await;
let mcp_changed = serde_json::to_value(&cfg.mcp)? != serde_json::to_value(&self.config().mcp)?;
*self.config.write().unwrap() = Arc::new(cfg);
*self.registry.write().unwrap() = Arc::new(registry);
if mcp_changed {
self.clone().start_mcp();
}
Ok(())
}
pub async fn patch_config(self: &Arc<Self>, patches: &[config::ConfigPatch]) -> Result<()> {
let text = config::apply_patches(&self.config_text()?, patches)?;
self.save_config(&text).await
}
pub async fn reload_providers(&self) {
let registry = Registry::build(&self.config()).await;
*self.registry.write().unwrap() = Arc::new(registry);
}
// --- events ---------------------------------------------------------
pub fn emit(&self, ev: Event) {
let _ = self.events.send(ev);
}
pub fn subscribe(&self) -> broadcast::Receiver<Event> {
self.events.subscribe()
}
/// Pending permission and question requests, re-sent to newly connected clients.
pub fn pending_events(&self) -> Vec<Event> {
let mut out: Vec<Event> = self.permissions.lock().unwrap().values().map(|p| p.event.clone()).collect();
out.extend(self.questions.lock().unwrap().values().map(|q| q.event.clone()));
out
}
// --- runtime --------------------------------------------------------
pub fn with_runtime<R>(&self, session_id: &str, f: impl FnOnce(&mut Runtime) -> R) -> R {
let mut rt = self.runtime.lock().unwrap();
f(rt.entry(session_id.to_string()).or_default())
}
pub fn is_busy(&self, session_id: &str) -> bool {
self.with_runtime(session_id, |r| r.cancel.is_some())
}
pub fn mark_read(&self, session_id: &str, path: &Path) {
self.with_runtime(session_id, |r| r.read_files.insert(path.to_path_buf()));
}
pub fn was_read(&self, session_id: &str, path: &Path) -> bool {
self.with_runtime(session_id, |r| r.read_files.contains(path))
}
pub fn session(&self, id: &str) -> Result<Session> {
self.store.get_session(id)?.ok_or_else(|| anyhow!("session not found"))
}
pub fn save_session(&self, mut s: Session) -> Result<Session> {
s.updated_at = now_ms();
self.store.save_session(&s)?;
self.emit(Event::SessionUpdated { session: s.clone() });
Ok(s)
}
pub fn set_todos(&self, session_id: &str, todos: Value) -> Result<()> {
let mut s = self.session(session_id)?;
s.todos = todos;
self.save_session(s)?;
Ok(())
}
/// Walk up the parent chain to the top-level session.
pub fn root_session_id(&self, session_id: &str) -> String {
let mut id = session_id.to_string();
for _ in 0..16 {
match self.store.get_session(&id).ok().flatten().and_then(|s| s.parent_id) {
Some(p) => id = p,
None => break,
}
}
id
}
// --- user interaction -------------------------------------------------
/// Forward questions to the UI and wait. None = dismissed.
pub async fn ask_user(
&self,
session_id: &str,
source: &str,
questions: Vec<Question>,
cancel: CancellationToken,
) -> Result<Option<Map<String, Value>>> {
let id = crate::types::new_id();
let event = Event::QuestionRequest {
id: id.clone(),
session_id: session_id.to_string(),
root_session_id: self.root_session_id(session_id),
source: source.to_string(),
questions,
};
let (tx, rx) = oneshot::channel();
self.questions.lock().unwrap().insert(id.clone(), PendingQuestion { event: event.clone(), tx });
self.emit(event);
let res = tokio::select! {
r = rx => r.unwrap_or(None),
_ = cancel.cancelled() => None,
};
self.questions.lock().unwrap().remove(&id);
self.emit(Event::QuestionResolved { id, session_id: session_id.to_string() });
Ok(res)
}
pub fn answer_question(&self, id: &str, answers: Option<Map<String, Value>>) -> Result<()> {
let p = self.questions.lock().unwrap().remove(id).ok_or_else(|| anyhow!("question not pending"))?;
let _ = p.tx.send(answers);
Ok(())
}
/// Ask the user to approve a tool call. Returns the reply and an optional
/// message (feedback on rejection).
pub async fn request_permission(
&self,
session_id: &str,
tool: &str,
title: String,
input: Value,
diff: Option<String>,
cancel: CancellationToken,
) -> (PermissionReply, Option<String>) {
let id = crate::types::new_id();
let event = Event::PermissionRequest {
id: id.clone(),
session_id: session_id.to_string(),
root_session_id: self.root_session_id(session_id),
tool: tool.to_string(),
title,
input,
diff,
};
let (tx, rx) = oneshot::channel();
self.permissions.lock().unwrap().insert(id.clone(), PendingPermission { event: event.clone(), tx });
self.emit(event);
let res = tokio::select! {
r = rx => r.unwrap_or((PermissionReply::Reject, None)),
_ = cancel.cancelled() => (PermissionReply::Reject, Some("aborted".into())),
};
self.permissions.lock().unwrap().remove(&id);
self.emit(Event::PermissionResolved { id, session_id: session_id.to_string() });
res
}
pub fn reply_permission(&self, id: &str, reply: PermissionReply, message: Option<String>) -> Result<()> {
let p = self.permissions.lock().unwrap().remove(id).ok_or_else(|| anyhow!("permission not pending"))?;
let _ = p.tx.send((reply, message));
Ok(())
}
}