app
AI Flow: Routing
Public Made by Adomby adom
The routing stage of AI Flow: routes a placed KiCad board connection by connection and reports what it made, what it could not and why. A guide, never a gate.
main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
//! The routing stage as a guided flow. `flows/flow.json` (compiled in) holds the steps in order, the
//! file that shows each one was done, and what the AI does in it. `next` names the first step whose
//! file is missing. It is a guide and never a gate: no command checks that an earlier step was done,
//! and a step may be skipped or done another way.
//!
//! A project keeps its state in `<project>/routing-flow/`: `state.json` (the board and spec it was
//! started on), `run.jsonl` (one line per command: time, the AI thread, what happened) and the files
//! the steps leave. Earlier ledger lines are never rewritten.
use serde_json::{json, Value};
use std::io::Write;
use std::path::{Path, PathBuf};
pub const FLOW_DIR: &str = "routing-flow";
pub struct Step {
pub name: String,
pub done: Vec<String>,
pub todo: String,
}
pub fn steps() -> Vec<Step> {
let v: Value = serde_json::from_str(include_str!("../flows/flow.json")).expect("flows/flow.json is valid JSON");
v["steps"]
.as_array()
.map(|a| {
a.iter()
.map(|s| Step {
name: s["name"].as_str().unwrap_or("").to_string(),
done: s["done"].as_array().map(|d| d.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default(),
todo: s["do"].as_str().unwrap_or("").to_string(),
})
.collect()
})
.unwrap_or_default()
}
pub struct Project {
pub dir: PathBuf,
pub flow_dir: PathBuf,
pub state: Value,
}
impl Project {
/// The nearest folder at or above `start` that holds `routing-flow/state.json`.
pub fn find(start: &Path) -> Option<Project> {
let mut d = std::fs::canonicalize(start).ok().or_else(|| Some(start.to_path_buf()));
while let Some(p) = d {
let s = p.join(FLOW_DIR).join("state.json");
if s.is_file() {
let state = std::fs::read_to_string(&s).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({}));
return Some(Project { flow_dir: p.join(FLOW_DIR), dir: p, state });
}
d = p.parent().map(Path::to_path_buf);
}
None
}
pub fn create(dir: &Path, state: Value) -> std::io::Result<Project> {
let flow_dir = dir.join(FLOW_DIR);
std::fs::create_dir_all(&flow_dir)?;
let p = Project { dir: dir.to_path_buf(), flow_dir, state };
std::fs::write(p.flow_dir.join("state.json"), serde_json::to_string_pretty(&p.state)? + "\n")?;
Ok(p)
}
pub fn path(&self, rel: &str) -> PathBuf {
self.flow_dir.join(rel)
}
/// One line in the ledger. A ledger that cannot be written never stops a command.
pub fn log(&self, thread: &str, ev: &str, data: Value) {
let line = json!({"t": now(), "aiThread": thread, "ev": ev, "data": data});
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(self.flow_dir.join("run.jsonl")) {
let _ = writeln!(f, "{line}");
}
}
pub fn is_done(&self, step: &Step) -> bool {
step.done.iter().all(|p| self.flow_dir.join(p).exists())
}
/// The first step whose file is missing, with its place in the list (1-based).
pub fn current<'a>(&self, steps: &'a [Step]) -> Option<(usize, &'a Step)> {
steps.iter().enumerate().find(|(_, s)| !self.is_done(s)).map(|(i, s)| (i + 1, s))
}
}
/// UTC now as 2026-11-10T12:00:00Z, from the system clock (no outside program).
pub fn now() -> String {
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) as i64;
let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
// Days since 1970-01-01 to a civil date (Howard Hinnant's algorithm).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 };
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3_600, rem % 3_600 / 60, rem % 60)
}
/// The file's sha256 where `sha256sum` is installed; empty otherwise (it is a record, not a lock).
pub fn sha256(p: &Path) -> String {
std::process::Command::new("sha256sum")
.arg(p)
.output()
.ok()
.and_then(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().next().map(str::to_string))
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_flow_file_reads_and_next_is_the_first_step_with_no_file() {
let s = steps();
assert!(s.len() >= 4 && s[0].name == "intake" && s.iter().all(|x| !x.done.is_empty() && !x.todo.is_empty()));
let d = std::env::temp_dir().join(format!("adom-route-flow-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
let p = Project::create(&d, json!({"board": "b.kicad_pcb"})).unwrap();
assert_eq!(p.current(&s).map(|(i, x)| (i, x.name.as_str())), Some((2, "plan")));
std::fs::write(p.path("plan.txt"), "x").unwrap();
assert_eq!(p.current(&s).map(|(_, x)| x.name.as_str()), Some("route"));
// A later step done first is fine: next still names the first one missing, and nothing refuses.
std::fs::write(p.path("check.json"), "{}").unwrap();
assert_eq!(p.current(&s).map(|(_, x)| x.name.as_str()), Some("route"));
p.log("t", "test", json!({}));
assert!(std::fs::read_to_string(p.path("run.jsonl")).unwrap().contains("\"ev\":\"test\""));
assert!(Project::find(&d.join(FLOW_DIR)).is_some());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn now_is_a_utc_stamp() {
let n = now();
assert!(n.len() == 20 && n.ends_with('Z') && n.as_bytes()[10] == b'T', "{n}");
}
}