app
Adom Chip Fetcher
Public Made by Adomby adom
Your whole parts library — manufacturer-grade chip CAD (symbol, footprint, 3D) one tap from your EDA tool.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666
// Self-enforcing rails for adom-chip-fetcher: heartbeat the dashboard, check that
// state is healthy before the next pup action, and emit `HINT:` lines that
// the calling AI reads back into context — so the binary itself becomes the
// source of truth for "what to do next" instead of relying on skill prose.
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
const HEARTBEAT_PATH: &str = "/tmp/adom-chip-fetcher-last-heartbeat.json";
const STALE_AFTER_SECS: u64 = 60;
const DEFAULT_PORT: u16 = 8786;
const DASHBOARD_SURFACE_PATH: &str = ".config/adom-chip-fetcher/dashboard.json";
/// Canonical sourcing-ladder priority. Single source of truth — `adom-chip-fetcher
/// sources` and the playbook prose both read from here. **Always traverse in
/// order; never skip to LCSC.** Direct user rule (2026-05-06): *"after Mouser
/// you should do digikey, and then only after that try LCSC. I really want to
/// avoid LCSC at all costs, but it is in the list if we're desperate."*
pub const SOURCE_LADDER: &[(&str, &str)] = &[
("adom-wiki", "wiki.adom.inc — reuse an already-published, Adom-vetted CAD bundle. TRY FIRST: if the part exists here we skip the external vendors entirely (free, fast, Adom-consistent geometry + provenance)."),
("manufacturer", "vendor.com — STEP, datasheet, lifecycle. ALWAYS try first, even when confident."),
("snapmagic", "snapeda.com — free CAD bundles, login-gated. Right after manufacturer."),
("mouser", "mouser.com — stock + pricing always; sometimes ECAD Models / CSE deep-links."),
("digikey", "digikey.com — broader catalog; STEP + datasheet on every product page."),
("arrow", "arrow.com — niche distributor."),
("cse_ul", "componentsearchengine.com / ultralibrarian.com — only when mfr links there."),
("lcsc", "datasheet.lcsc.com / lcsc.com — LAST RESORT. Avoid if possible; tell user we couldn't find it on the manufacturer's site instead."),
];
/// Where the user wants to see the dashboard. Pup is the default — adom-chip-fetcher
/// needs pup anyway to drive vendor sites, so the dashboard tagging along is
/// the lowest-friction path. Hydrogen webview is the alternative for users on
/// big monitors who prefer the workspace-integrated panel.
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
pub enum DashboardSurface {
Pup,
Webview,
}
impl DashboardSurface {
fn parse(s: &str) -> Self {
match s.trim().to_lowercase().as_str() {
"webview" | "hydrogen" => Self::Webview,
_ => Self::Pup,
}
}
fn as_str(&self) -> &'static str {
match self { Self::Pup => "pup", Self::Webview => "webview" }
}
}
#[derive(Serialize, Deserialize, Clone)]
struct DashboardConfig { surface: String }
fn dashboard_config_path() -> Option<std::path::PathBuf> {
let home = std::env::var("HOME").ok()?;
Some(std::path::PathBuf::from(home).join(DASHBOARD_SURFACE_PATH))
}
pub fn get_dashboard_surface() -> DashboardSurface {
let path = match dashboard_config_path() { Some(p) => p, None => return DashboardSurface::Pup };
match std::fs::read_to_string(&path) {
Ok(s) => match serde_json::from_str::<DashboardConfig>(&s) {
Ok(c) => DashboardSurface::parse(&c.surface),
Err(_) => DashboardSurface::Pup,
},
Err(_) => DashboardSurface::Pup,
}
}
pub fn set_dashboard_surface(surface: DashboardSurface) -> Result<()> {
let path = dashboard_config_path()
.ok_or_else(|| anyhow::anyhow!("HOME not set"))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let cfg = DashboardConfig { surface: surface.as_str().to_string() };
std::fs::write(&path, serde_json::to_string_pretty(&cfg)?)?;
println!("OK: dashboard surface set to {:?} ({})", surface, path.display());
Ok(())
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Heartbeat {
pub mpn: String,
pub stage: String,
pub ts: u64,
}
/// First-heartbeat stub: create an empty `library/<MPN>/info.json` so the
/// dashboard's `inventory::scan` walks find the new chip and render an empty
/// card the user can scroll to while we work. No-op if `info.json` already
/// exists (we don't want to clobber a partial fetch's data). Returns `true`
/// when a new stub was created (i.e. this is the very first heartbeat for
/// this MPN), so the caller can surface the manufacturer-first hint.
fn ensure_stub_card(mpn: &str, stage: &str) -> Result<bool> {
if mpn.is_empty() || mpn == "ZZZ" {
return Ok(false);
}
let dir = crate::library::root().join(mpn);
let info_path = dir.join("info.json");
if info_path.exists() {
return Ok(false);
}
std::fs::create_dir_all(&dir)?;
let info = serde_json::json!({
"mpn": mpn,
"state": "in_progress",
"stage": stage,
"started_at": now_secs(),
});
std::fs::write(info_path, serde_json::to_string_pretty(&info)?)?;
Ok(true)
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn heartbeat(mpn: &str, stage: &str) -> Result<()> {
let hb = Heartbeat {
mpn: mpn.to_string(),
stage: stage.to_string(),
ts: now_secs(),
};
let body = serde_json::to_string(&hb)?;
let _ = std::fs::write(HEARTBEAT_PATH, &body);
// Stub-card on first heartbeat: if the chip has no library/<MPN>/ directory
// yet, create one with an in-progress info.json so the dashboard renders an
// empty placeholder card immediately. Without this, the user has nothing
// to scroll to during a fresh fetch — the chip is invisible until the
// first file lands.
let was_new = ensure_stub_card(mpn, stage).unwrap_or(false);
let url = format!("http://127.0.0.1:{DEFAULT_PORT}/api/activity");
let dashboard_ok = Command::new("curl")
.args([
"-fs", "-m", "2", "-X", "POST", &url,
"-H", "Content-Type: application/json",
"-d", &body,
])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
println!("OK: heartbeat {mpn} → {stage:?}");
if !dashboard_ok {
println!("WARN: dashboard unreachable at {url}. Heartbeat cached locally.");
print_hint("start the dashboard with `adom-chip-fetcher app` so users can see live progress");
}
// Auto-scroll the MPN's dashboard card into view in the adom-chip-fetcher pup
// session. This is the rule John asked for — the binary enforces "scroll
// the active card into focus" automatically rather than relying on the AI
// to remember it on every heartbeat. No-op if pup isn't running.
match auto_scroll_card(mpn) {
Ok(true) => println!("OK: scrolled card {mpn} into focus on dashboard"),
Ok(false) => {} // pup not up, or dashboard surface is webview — silently skip
Err(e) => println!("WARN: could not scroll card {mpn} into focus: {e}"),
}
// First heartbeat for this MPN: print the sourcing-ladder reminder so the
// AI doesn't skip ahead to LCSC. This is "rules made programmatic" —
// every fresh fetch sees the canonical order in stdout, not buried in a
// skill file the AI may not have re-read this turn.
if was_new {
println!();
print_hint(&format!("new stub created for {mpn}. ALWAYS start at the manufacturer's site, then walk the ladder IN ORDER (no skipping). See `adom-chip-fetcher sources` for the full priority list."));
print_hint("Order: Mfr → SnapMagic → Mouser → DigiKey → Arrow → CSE/UL → LCSC. LCSC is LAST RESORT — surface 'no manufacturer CAD' to user before reaching for it.");
}
// Card-completeness check on every heartbeat. The AI must not claim "done"
// while the active card is missing core CAD. Surface what's missing now,
// and also count how many other cards in the library are still stubs so
// the AI can't quietly walk away from a half-finished batch.
let _ = print_completeness_warnings(mpn);
// Auto-record source attempts in info.json.fetched_via_chain. The AI
// doesn't have to remember to write the chain; the binary parses the
// stage label and logs it. `adom-chip-fetcher next-source <MPN>` then knows
// what's been tried.
if let Some((source, outcome)) = crate::sourcing::detect_source_attempt(stage) {
let added = crate::sourcing::append_chain(mpn, source, &outcome, Some(stage)).unwrap_or(false);
if added {
println!("OK: chain {} → {} recorded for {mpn} (run `adom-chip-fetcher next-source {mpn}` to see what's next)", source.as_slug(), outcome);
}
}
// Stage-order state machine. Warn if the AI seems to skip a phase
// (e.g. heartbeats "imported" without ever heartbeating "downloading").
let _ = check_stage_order(mpn, stage);
Ok(())
}
/// A QUIET heartbeat: refresh the dashboard's activity timestamp + stage only.
/// No stdout, no stub creation, no auto-scroll, no chain/stage bookkeeping.
/// This is what the background ticker fires every few seconds so the "working
/// on" bar stays LIVE during a long blocking op — without spamming the log or
/// re-running the heavy per-heartbeat side-effects.
pub fn heartbeat_quiet(mpn: &str, stage: &str) {
let hb = Heartbeat { mpn: mpn.to_string(), stage: stage.to_string(), ts: now_secs() };
if let Ok(body) = serde_json::to_string(&hb) {
let _ = std::fs::write(HEARTBEAT_PATH, &body);
let url = format!("http://127.0.0.1:{DEFAULT_PORT}/api/activity");
let _ = Command::new("curl")
.args(["-fs", "-m", "2", "-X", "POST", &url, "-H", "Content-Type: application/json", "-d", &body])
.output();
}
}
/// Keeps the dashboard "working on" bar LIVE during a long blocking call (a 40s
/// browser XHR, a slow import). Emits one loud heartbeat up front, then a quiet
/// one every ~6s on a background thread until stopped (or dropped). Without this
/// the bar goes stale after ACTIVITY_STALE_SECS mid-operation and the user sees
/// no live status — exactly John's complaint.
pub struct HeartbeatTicker {
stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
handle: Option<std::thread::JoinHandle<()>>,
}
impl HeartbeatTicker {
pub fn start(mpn: &str, stage: &str) -> Self {
use std::sync::atomic::Ordering;
let _ = heartbeat(mpn, stage); // one loud heartbeat (stub card, scroll, log)
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let (mpn, stage, s2) = (mpn.to_string(), stage.to_string(), stop.clone());
let handle = std::thread::spawn(move || {
let mut elapsed_ms = 0u64;
while !s2.load(Ordering::Relaxed) {
std::thread::sleep(std::time::Duration::from_millis(500));
elapsed_ms += 500;
if elapsed_ms >= 6000 {
elapsed_ms = 0;
if !s2.load(Ordering::Relaxed) {
heartbeat_quiet(&mpn, &stage);
}
}
}
});
HeartbeatTicker { stop, handle: Some(handle) }
}
pub fn stop(mut self) { self.shutdown(); }
fn shutdown(&mut self) {
self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
if let Some(h) = self.handle.take() { let _ = h.join(); }
}
}
impl Drop for HeartbeatTicker {
fn drop(&mut self) { self.shutdown(); }
}
/// Track stage-order per MPN in /tmp/adom-chip-fetcher-stage-<MPN>.json. Warn
/// when the AI heartbeats a later phase (e.g. 5=importing) without having
/// hit the prior phase (3=downloading). Doesn't block — just nudges.
fn check_stage_order(mpn: &str, stage: &str) -> Result<()> {
let phase = match crate::sourcing::stage_phase(stage) {
Some(p) => p,
None => return Ok(()),
};
let path = std::path::PathBuf::from(format!("/tmp/adom-chip-fetcher-stage-{mpn}.json"));
// Did we ever record a phase before? Distinguish "no prev recorded"
// from "prev was phase 0 (queued)" — both are u8 0 but only one is
// a legitimate prior state.
let had_prev = path.exists();
let prev: u8 = if had_prev {
std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v.get("phase").and_then(|p| p.as_u64()).map(|n| n as u8))
.unwrap_or(0)
} else {
0
};
if had_prev && phase > prev + 1 {
let phase_names = ["queued", "probing", "found", "downloading", "pulling", "importing", "imported"];
let prev_name = phase_names.get(prev as usize).copied().unwrap_or("?");
let cur_name = phase_names.get(phase as usize).copied().unwrap_or("?");
println!("WARN: stage jumped {prev}→{phase} for {mpn} ({prev_name}→{cur_name}) — skipped intermediate stages. Did the file actually land? Did you heartbeat \"downloading\" / \"pulling\" / \"importing\"?");
}
let _ = std::fs::write(&path, serde_json::json!({"phase": phase, "stage": stage}).to_string());
Ok(())
}
/// Walk the active chip's directory and print explicit `WARN:` + `HINT:` lines
/// for each missing core artifact. Also counts how many OTHER chips in the
/// library are incomplete so the AI sees the batch picture, not just one chip.
/// Pure check; never errors. The deterministic gate is `adom-chip-fetcher gate` —
/// this is the inline version that fires on every heartbeat.
fn print_completeness_warnings(mpn: &str) -> Result<()> {
let lib = match crate::inventory::scan() {
Ok(l) => l,
Err(_) => return Ok(()),
};
let active = lib.chips.iter().find(|c| c.mpn == mpn);
let total = lib.chips.len();
let incomplete = lib.chips.iter()
.filter(|c| !(c.has_pdf && c.has_step && c.has_sym && c.has_mod))
.count();
if let Some(c) = active {
let mut missing: Vec<&'static str> = Vec::new();
if !c.has_pdf { missing.push("datasheet.pdf"); }
if !c.has_step { missing.push("STEP"); }
if !c.has_sym { missing.push("KiCad .kicad_sym"); }
if !c.has_mod { missing.push("KiCad .kicad_mod"); }
if !missing.is_empty() {
println!();
println!("WARN: card {mpn} is INCOMPLETE — missing: {}", missing.join(", "));
print_hint(&format!("do NOT claim done on {mpn}. Walk the ladder for each missing piece. `adom-chip-fetcher gate` for full status."));
}
}
if incomplete > 0 {
println!("WARN: {incomplete}/{total} cards in library are still incomplete (run `adom-chip-fetcher gate` to see all).");
}
Ok(())
}
/// Scroll the dashboard pup window so the active MPN's card is centered in
/// view, with a brief outline highlight so the user's eye lands on it. Returns
/// `Ok(true)` when the scroll was issued, `Ok(false)` when pup isn't the
/// active surface (no error, no work needed), `Err` only on adom-desktop
/// failure.
fn auto_scroll_card(mpn: &str) -> Result<bool> {
let safe_mpn = mpn.replace('"', "");
let expr = format!(
"(()=>{{const m={:?};const c=document.querySelector(`[data-mpn=\"${{m}}\"]`);\
if(c){{c.scrollIntoView({{behavior:'smooth',block:'center'}});\
c.style.outline='3px solid #5cf';\
setTimeout(()=>c.style.outline='',2500);return 'card';}}\
const b=document.querySelector('.activity-banner,.heartbeat-banner,[data-activity]');\
if(b){{b.scrollIntoView({{behavior:'smooth',block:'start'}});return 'banner';}}\
window.scrollTo({{top:0,behavior:'smooth'}});return 'top';}})()",
safe_mpn
);
let body = serde_json::json!({
"sessionId": "adom-chip-fetcher-dashboard",
"expr": expr,
}).to_string();
let out = Command::new("adom-desktop")
.args(["browser_eval", &body])
.output()?;
Ok(out.status.success())
}
pub fn last() -> Option<Heartbeat> {
let s = std::fs::read_to_string(HEARTBEAT_PATH).ok()?;
serde_json::from_str(&s).ok()
}
pub fn age_secs(hb: &Heartbeat) -> u64 {
now_secs().saturating_sub(hb.ts)
}
/// Make sure the dashboard is up AND visible to the user. Idempotent —
/// running this every time is the point. Three checks, each with a fix:
/// 1. HTTP server reachable on $DEFAULT_PORT? If not, spawn `adom-chip-fetcher
/// serve` detached.
/// 2. Pup window with sessionId "adom-chip-fetcher-dashboard" pointing at the dashboard?
/// If not, open one (or open Hydrogen tab, per surface preference).
/// 3. (best-effort) Foreground the pup window so the user sees it.
///
/// `force_open` skips check (2)'s "already open" path and re-opens — useful
/// when the user explicitly wants the dashboard front-and-center.
pub fn dashboard_ensure(force_open: bool) -> Result<()> {
// Step 1 — HTTP server
if !dashboard_reachable() {
println!("WARN: dashboard server not running; starting it...");
let exe = std::env::current_exe()
.unwrap_or_else(|_| std::path::PathBuf::from("adom-chip-fetcher"));
// Detach: own session, stdout/stderr to a log file, parent doesn't wait.
let log = std::fs::OpenOptions::new()
.create(true).append(true)
.open("/tmp/cf-dashboard.log")
.unwrap_or_else(|_| std::fs::File::create("/dev/null").unwrap());
let log_err = log.try_clone().unwrap();
let _ = std::process::Command::new(exe)
.args(["serve", "--port", &DEFAULT_PORT.to_string()])
.stdout(log)
.stderr(log_err)
.stdin(std::process::Stdio::null())
.spawn()
.map_err(|e| anyhow::anyhow!("spawn serve: {e}"))?;
// Give it a moment to bind.
for _ in 0..20 {
std::thread::sleep(std::time::Duration::from_millis(150));
if dashboard_reachable() { break; }
}
if dashboard_reachable() {
println!("OK: dashboard server up on http://127.0.0.1:{DEFAULT_PORT}/");
} else {
anyhow::bail!("dashboard server failed to start within 3s — see /tmp/cf-dashboard.log");
}
} else {
println!("OK: dashboard server already running on :{DEFAULT_PORT}");
}
// Step 2 — visible surface
let surface = get_dashboard_surface();
match surface {
DashboardSurface::Pup => ensure_pup_dashboard_window(force_open)?,
DashboardSurface::Webview => ensure_hydrogen_dashboard_tab(force_open)?,
}
Ok(())
}
fn dashboard_proxy_url() -> String {
// The dashboard pup window runs on the user's Windows desktop — it can't
// reach 127.0.0.1 on the Docker container. Use the Adom cloud proxy URL
// so the pup browser can access the dashboard through HTTPS.
if let Ok(host) = std::env::var("ADOM_PROXY_HOST") {
format!("https://{host}/proxy/{DEFAULT_PORT}/")
} else if let Ok(slug) = std::env::var("ADOM_CONTAINER_SLUG") {
format!("https://{slug}.adom.cloud/proxy/{DEFAULT_PORT}/")
} else if let Ok(tmpl) = std::env::var("VSCODE_PROXY_URI") {
tmpl.replace("{{port}}", &DEFAULT_PORT.to_string())
} else {
format!("http://127.0.0.1:{DEFAULT_PORT}/")
}
}
fn ensure_pup_dashboard_window(force_open: bool) -> Result<()> {
// Native browser: the dashboard is TAB 1 of the single "adom-chip-fetcher" window
// (scrape sites are tabs beside it). Never spawn a second window — that's
// the "why do I have a native browser AND a pup?" bug.
if crate::pup::is_native() {
let url = dashboard_proxy_url();
if !force_open && crate::pup::session_has_url("adom-chip-fetcher", &format!("/proxy/{DEFAULT_PORT}/")) {
println!("OK: dashboard is tab 1 of the adom-chip-fetcher window (native browser)");
return Ok(());
}
crate::pup::open_window("adom-chip-fetcher", "adom-chip-fetcher", &url)?;
println!("OK: opened the adom-chip-fetcher dashboard as tab 1 of your native browser window");
return Ok(());
}
let already_open = pup_session_open("adom-chip-fetcher-dashboard").unwrap_or(false);
if already_open && !force_open {
println!("OK: dashboard pup window 'adom-chip-fetcher-dashboard' already open");
let _ = Command::new("adom-desktop")
.args(["browser_focus_window", "{\"sessionId\":\"adom-chip-fetcher-dashboard\"}"])
.output();
return Ok(());
}
let url = dashboard_proxy_url();
let body = serde_json::json!({
"sessionId": "adom-chip-fetcher-dashboard",
"profile": "adom-chip-fetcher",
"url": url,
}).to_string();
let out = Command::new("adom-desktop")
.args(["browser_open_window", &body])
.output();
match out {
Ok(o) if o.status.success() => {
println!("OK: opened dashboard in pup at {}", dashboard_proxy_url());
let _ = Command::new("adom-desktop")
.args(["browser_focus_window", "{\"sessionId\":\"adom-chip-fetcher-dashboard\"}"])
.output();
Ok(())
}
Ok(o) => {
let stdout = String::from_utf8_lossy(&o.stdout).to_string();
let stderr = String::from_utf8_lossy(&o.stderr).to_string();
let detail = if stdout.is_empty() { stderr } else { stdout };
anyhow::bail!("browser_open_window failed: {detail}")
}
Err(e) => anyhow::bail!("could not run adom-desktop: {e}"),
}
}
fn ensure_hydrogen_dashboard_tab(_force_open: bool) -> Result<()> {
let url = format!("http://127.0.0.1:{DEFAULT_PORT}/");
// adom-cli is the canonical Hydrogen-tab driver. If it's missing, fall
// back to pup so the user still sees the dashboard.
let status = Command::new("adom-cli")
.args(["workspace", "tab-open", "--url", &url, "--title", "adom-chip-fetcher"])
.status();
match status {
Ok(s) if s.success() => {
println!("OK: opened dashboard in Hydrogen webview tab");
Ok(())
}
_ => {
println!("WARN: adom-cli workspace tab-open failed — falling back to pup");
ensure_pup_dashboard_window(false)
}
}
}
fn dashboard_reachable() -> bool {
let url = format!("http://127.0.0.1:{DEFAULT_PORT}/api/library");
Command::new("curl")
.args(["-fs", "-m", "2", "-o", "/dev/null", &url])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn pup_session_open(name: &str) -> Option<bool> {
if list_contains_session(name)? {
return Some(true);
}
// First lookup miss: maybe the bridge dropped + reconnected and lost the
// session reference even though Chrome is still alive (the v1.5.0 bug).
// v1.5.1's browser_rescan rebuilds the session map from the actual
// Chrome targets — call it once before declaring the session gone.
let rescan = Command::new("adom-desktop")
.args(["browser_rescan", "{}"])
.output()
.ok()?;
if !rescan.status.success() { return Some(false); }
let r: serde_json::Value = serde_json::from_slice(&rescan.stdout).ok()?;
let recovered = r.get("liveSessions")
.and_then(|l| l.as_array())
.map(|arr| arr.iter().any(|v| v.as_str() == Some(name)))
.unwrap_or(false);
if recovered {
println!("OK: browser_rescan recovered session {name:?}");
return Some(true);
}
Some(false)
}
fn list_contains_session(name: &str) -> Option<bool> {
let out = Command::new("adom-desktop")
.args(["browser_list_windows", "{}"])
.output()
.ok()?;
let s = String::from_utf8_lossy(&out.stdout);
let compact = format!("\"sessionId\":\"{name}\"");
let spaced = format!("\"sessionId\": \"{name}\"");
Some(s.contains(&compact) || s.contains(&spaced))
}
pub fn selfcheck() -> Result<()> {
// Dashboard rail: auto-(re)open if missing rather than just complaining.
// The user shouldn't have to remember "is the dashboard up?" — that's
// the binary's job.
if let Err(e) = dashboard_ensure(false) {
println!("WARN: dashboard_ensure: {e}");
}
let mut errs: Vec<String> = vec![];
let mut warns: Vec<String> = vec![];
if dashboard_reachable() {
println!("OK: dashboard reachable at http://127.0.0.1:{DEFAULT_PORT}/");
} else {
errs.push(format!(
"dashboard NOT reachable at http://127.0.0.1:{DEFAULT_PORT}/"
));
}
match last() {
Some(hb) => {
let age = age_secs(&hb);
if age <= STALE_AFTER_SECS {
println!(
"OK: heartbeat {age}s old — {} → {:?}",
hb.mpn, hb.stage
);
} else {
warns.push(format!(
"heartbeat is {age}s old (stale-after = {STALE_AFTER_SECS}s) — last was {} → {:?}",
hb.mpn, hb.stage
));
}
}
None => warns.push(
"no heartbeat on file — the dashboard hasn't seen any activity from this session".into(),
),
}
match pup_session_open("adom-chip-fetcher") {
Some(true) => println!("OK: pup session 'adom-chip-fetcher' is open"),
Some(false) => warns.push(
"pup session 'adom-chip-fetcher' is NOT open — fetches need this exact sessionId".into(),
),
None => warns.push("could not query adom-desktop browser_list_windows".into()),
}
if !errs.is_empty() {
println!();
for e in &errs {
println!("ERROR: {e}");
}
}
if !warns.is_empty() {
println!();
for w in &warns {
println!("WARN: {w}");
}
}
println!();
if errs.is_empty() && warns.is_empty() {
print_hint("all checks passed — continue with your fetch. Heartbeat each stage transition with `adom-chip-fetcher heartbeat <MPN> \"<stage>\"` before any pup action.");
} else {
if !dashboard_reachable() {
print_hint("start the dashboard FIRST: `adom-chip-fetcher app` (Hydrogen tab) or `adom-chip-fetcher serve` (HTTP only) — every other rule depends on the dashboard being up.");
}
match last() {
None => print_hint("send your first heartbeat: `adom-chip-fetcher heartbeat <MPN> \"starting fetch\"`"),
Some(hb) if age_secs(&hb) > STALE_AFTER_SECS => print_hint(&format!(
"heartbeat is stale — refresh with `adom-chip-fetcher heartbeat {} \"<current stage>\"`",
hb.mpn
)),
_ => {}
}
if matches!(pup_session_open("adom-chip-fetcher"), Some(false)) {
print_hint("open the pup window before fetching: navigate to the vendor URL with `sessionId:\"adom-chip-fetcher\", profile:\"adom-chip-fetcher\"`");
}
}
if !errs.is_empty() {
anyhow::bail!("selfcheck failed with {} error(s)", errs.len());
}
Ok(())
}
pub fn print_hint(line: &str) {
println!("HINT: {line}");
}
/// Emit a "what to do next" line based on current state. Used as the tail of
/// every subcommand's stdout — the AI reads this back into context and the
/// next action becomes mechanical instead of remembered.
pub fn print_next_after(stage: &str, mpn: Option<&str>) {
match (stage, mpn) {
("fetched", Some(m)) => {
print_hint(&format!("heartbeat the result: `adom-chip-fetcher heartbeat {m} imported`"));
print_hint(&format!("verify on disk: `adom-chip-fetcher status {m}`"));
}
("imported", Some(m)) => {
print_hint(&format!("heartbeat: `adom-chip-fetcher heartbeat {m} imported`"));
print_hint(&format!("dashboard view: `adom-chip-fetcher status {m}`"));
}
("pulled", Some(m)) => {
print_hint(&format!("heartbeat: `adom-chip-fetcher heartbeat {m} \"pulled to incoming\"`"));
print_hint("import next: `adom-chip-fetcher import <path> --mpn <MPN>`");
}
("listed", _) => {
print_hint("inspect one: `adom-chip-fetcher status <MPN>`");
print_hint("fetch a missing one: `adom-chip-fetcher fetch <MPN>`");
}
("status", Some(m)) => {
print_hint(&format!("if anything is missing, fetch with `adom-chip-fetcher fetch {m}`"));
print_hint(&format!("heartbeat any user-visible action with `adom-chip-fetcher heartbeat {m} \"<stage>\"`"));
}
("login", _) => {
print_hint("after sign-in, heartbeat: `adom-chip-fetcher heartbeat <MPN> \"logged in to <source>\"`");
print_hint("verify rails: `adom-chip-fetcher selfcheck`");
}
_ => {}
}
}