mirror of
https://github.com/storytold/craft-anything.git
synced 2026-11-05 20:47:45 +01:00
6a120011a1
* Explain failed agent runs, keep a log to open, and stop offering git servers as computers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fix a lock-up: the build's agent setup took the session lock twice in one statement Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
197 lines
8.4 KiB
Rust
197 lines
8.4 KiB
Rust
//! A project's conversation, as data: what the person asked, what the builder did and said, its
|
|
//! checklist, questions and checks. It's kept in the project folder (`feed.json`); a rebuild run
|
|
//! appends to it as it goes (`ai::rebuild::feed`), and every client reads the same file.
|
|
|
|
use crate::inbox::{Inbox, Request};
|
|
use crate::workspace::{CheckResult, Status, Step, Workspace};
|
|
use craft_anything_evidence::{ErrorCode, ToolError};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use std::collections::HashSet;
|
|
use std::path::Path;
|
|
|
|
/// Where the conversation is kept in the project folder.
|
|
pub const FEED_FILE: &str = "feed.json";
|
|
|
|
/// The format written today (2 added `Paused`); files without a version are the earlier bare list.
|
|
pub const FEED_VERSION: u32 = 2;
|
|
|
|
/// One step of the builder's own checklist (from its TodoWrite list).
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct PlanStep {
|
|
pub text: String,
|
|
/// What it says while doing this step ("Drawing the map").
|
|
pub doing: String,
|
|
pub status: PlanStatus,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum PlanStatus {
|
|
Todo,
|
|
Doing,
|
|
Done,
|
|
}
|
|
|
|
/// One entry in a project's activity feed.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub enum FeedItem {
|
|
/// The builder's checklist, kept up to date in place (shown pinned above the prompt box).
|
|
Plan(Vec<PlanStep>),
|
|
User(String),
|
|
Step {
|
|
step: Step,
|
|
text: String,
|
|
},
|
|
/// The agent's words (streamed).
|
|
Agent {
|
|
text: String,
|
|
streaming: bool,
|
|
},
|
|
/// A run of tool calls, collapsed in Simple mode: (tool, finished ok?).
|
|
Work {
|
|
tools: Vec<(String, Option<bool>)>,
|
|
notes: Vec<String>,
|
|
},
|
|
/// A question or approval; `answer` is the person's reply once given.
|
|
Ask {
|
|
request: Request,
|
|
answer: Option<String>,
|
|
},
|
|
Check(CheckResult),
|
|
Error {
|
|
message: String,
|
|
remediation: Option<String>,
|
|
/// A log with everything known about the failure, to open and inspect.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
log: Option<std::path::PathBuf>,
|
|
},
|
|
/// The person paused the run (not a failure); a message or Continue carries on.
|
|
Paused,
|
|
}
|
|
|
|
/// Mark a question or approval answered, with the words shown for the answer.
|
|
pub fn answered(feed: &mut [FeedItem], id: &str, shown: &str) {
|
|
for item in feed {
|
|
if let FeedItem::Ask { request, answer: a @ None } = item
|
|
&& request.id == id
|
|
{
|
|
*a = Some(shown.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The saved file: `{version, items}`. Earlier files were the bare list.
|
|
#[derive(Serialize, Deserialize)]
|
|
struct Saved {
|
|
version: u32,
|
|
items: Vec<FeedItem>,
|
|
}
|
|
|
|
/// The feed for a project: what was saved, else rebuilt from its manifest. Questions still
|
|
/// waiting in its inbox are always there.
|
|
pub fn load(ws: &Workspace) -> Vec<FeedItem> {
|
|
let saved = std::fs::read(ws.root.join(FEED_FILE)).ok().and_then(|b| {
|
|
serde_json::from_slice::<Saved>(&b)
|
|
.ok()
|
|
.filter(|s| s.version <= FEED_VERSION)
|
|
.map(|s| s.items)
|
|
.or_else(|| serde_json::from_slice::<Vec<FeedItem>>(&b).ok())
|
|
});
|
|
let Some(mut feed) = saved else { return history(ws) };
|
|
// Nothing is streaming any more after a restart.
|
|
for item in &mut feed {
|
|
if let FeedItem::Agent { streaming, .. } = item {
|
|
*streaming = false;
|
|
}
|
|
}
|
|
let shown: HashSet<String> = feed.iter().filter_map(|i| if let FeedItem::Ask { request, .. } = i { Some(request.id.clone()) } else { None }).collect();
|
|
feed.extend(Inbox::new(ws.inbox_dir()).pending().into_iter().filter(|r| !shown.contains(&r.id)).map(|request| FeedItem::Ask { request, answer: None }));
|
|
feed
|
|
}
|
|
|
|
/// A feed rebuilt from what a project saved: the request, the checks, the agent's last words,
|
|
/// and anything still waiting for an answer.
|
|
pub fn history(ws: &Workspace) -> Vec<FeedItem> {
|
|
let m = &ws.manifest;
|
|
let mut feed = vec![FeedItem::User(m.request.clone())];
|
|
feed.extend(m.checks.iter().cloned().map(FeedItem::Check));
|
|
if !m.last_reply.is_empty() {
|
|
feed.push(FeedItem::Agent { text: m.last_reply.clone(), streaming: false });
|
|
}
|
|
if matches!(m.status, Status::NeedsYou) && !m.note.is_empty() {
|
|
feed.push(FeedItem::Error { message: m.note.clone(), remediation: None, log: None });
|
|
}
|
|
feed.extend(Inbox::new(ws.inbox_dir()).pending().into_iter().map(|request| FeedItem::Ask { request, answer: None }));
|
|
feed
|
|
}
|
|
|
|
/// Write the feed into the project folder (only one that exists; never creates one). A feed
|
|
/// saved by a newer Craft Anything is never written over (docs/file-formats.md).
|
|
pub fn save(root: &Path, feed: &[FeedItem]) -> Result<(), ToolError> {
|
|
if !root.is_dir() {
|
|
return Ok(());
|
|
}
|
|
let newer = std::fs::read(root.join(FEED_FILE))
|
|
.ok()
|
|
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
|
|
.and_then(|v| v.get("version").and_then(Value::as_u64))
|
|
.is_some_and(|v| v > u64::from(FEED_VERSION));
|
|
if newer {
|
|
return Err(ToolError::new(ErrorCode::NotSupported, "This project's conversation was saved by a newer Craft Anything, so it isn't changed.")
|
|
.with_remediation("Update Craft Anything to keep its conversation up to date."));
|
|
}
|
|
let json = serde_json::to_vec(&Saved { version: FEED_VERSION, items: feed.to_vec() }).map_err(|e| ToolError::internal(e.to_string()))?;
|
|
crate::project::write_atomic(&root.join(FEED_FILE), &json)
|
|
}
|
|
|
|
/// Record an answer given while no run is going (straight into the saved feed).
|
|
pub fn record_answer(ws: &Workspace, id: &str, shown: &str) -> Result<(), ToolError> {
|
|
let mut feed = load(ws);
|
|
answered(&mut feed, id, shown);
|
|
save(&ws.root, &feed)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::inbox::RequestKind;
|
|
use crate::workspace::TargetRef;
|
|
|
|
#[test]
|
|
fn history_rebuilds_from_the_manifest() {
|
|
let d = tempfile::tempdir().unwrap();
|
|
let mut ws = Workspace::create(d.path(), "x", "Clone it", TargetRef::None).unwrap();
|
|
ws.manifest.checks.push(CheckResult { similarity: Some(70.0), ..CheckResult::new(CheckResult::WEB_VISUAL, 1, true, "") });
|
|
ws.manifest.last_reply = "Built a page.".into();
|
|
ws.save().unwrap();
|
|
Inbox::new(ws.inbox_dir()).post(RequestKind::Question, "Blue or red?", "", vec![], true, None).unwrap();
|
|
let f = history(&ws);
|
|
assert!(matches!(&f[0], FeedItem::User(r) if r == "Clone it"));
|
|
assert!(matches!(f.last(), Some(FeedItem::Ask { answer: None, .. })), "pending questions come back");
|
|
assert_eq!(f.len(), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn older_files_load_answers_are_kept_and_newer_files_are_left_alone() {
|
|
let d = tempfile::tempdir().unwrap();
|
|
let ws = Workspace::create(d.path(), "x", "Clone it", TargetRef::None).unwrap();
|
|
save(&ws.root, &[FeedItem::User("Clone it".into()), FeedItem::Agent { text: "Done.".into(), streaming: true }]).unwrap();
|
|
assert!(matches!(load(&ws).as_slice(), [FeedItem::User(_), FeedItem::Agent { streaming: false, .. }]), "nothing streams after a restart");
|
|
let raw: Value = serde_json::from_slice(&std::fs::read(ws.root.join(FEED_FILE)).unwrap()).unwrap();
|
|
assert_eq!(raw["version"], FEED_VERSION);
|
|
// A feed saved before versioning (the bare list) still loads.
|
|
std::fs::write(ws.root.join(FEED_FILE), br#"[{"User":"Old request"}]"#).unwrap();
|
|
assert!(matches!(load(&ws).as_slice(), [FeedItem::User(r)] if r == "Old request"));
|
|
// Answers given with no run going are saved too.
|
|
Inbox::new(ws.inbox_dir()).post(RequestKind::Question, "Blue or red?", "", vec![], true, None).unwrap();
|
|
let id = load(&ws).iter().find_map(|i| if let FeedItem::Ask { request, .. } = i { Some(request.id.clone()) } else { None }).unwrap();
|
|
record_answer(&ws, &id, "Blue").unwrap();
|
|
assert!(std::fs::read_to_string(ws.root.join(FEED_FILE)).unwrap().contains("\"Blue\""));
|
|
// One from a newer Craft Anything is shown as far as we can, never written over.
|
|
let newer = br#"{"version":99,"items":[{"User":"From the future"}],"extra":1}"#;
|
|
std::fs::write(ws.root.join(FEED_FILE), newer).unwrap();
|
|
assert!(save(&ws.root, &[FeedItem::User("x".into())]).is_err());
|
|
assert_eq!(std::fs::read(ws.root.join(FEED_FILE)).unwrap(), newer);
|
|
}
|
|
}
|