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,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(())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user