feat: books.show — the agent puts things on the viewer's screen
books.show asks the viewer to open an entry, an account register, a report (dates, month/quarter/year columns, prior-period or prior-year comparison), a document, a search, or a page. The request lives outside the books (runtime dir, keyed by data dir, written atomically); the viewer watches it beside the journal and applies books before navigating. 66 tools. Site: the dashboard is the hero again; 'Here's what using it looks like' shows the session beside the viewer, ending in books.show; the cash page replaces the repeated overview lower down. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
17 files changed
+626
-22
No files matched your search
@@ -173,6 +173,10 @@ Bank feeds aren't automatic here: import the bank's CSV export
|
||||
|
||||
- Every transaction: draft, preview, confirm with the human, post, attach
|
||||
the receipt. Tag payments to contractors with their vendor (1099s).
|
||||
- The human usually has the viewer open beside you (`just gui`). When they
|
||||
ask to see something, and after you post, put it on their screen with
|
||||
`books.show` (an entry, an account register, a report, a document, or a
|
||||
search).
|
||||
- `books.digest` is the review surface — "what did you do with my books
|
||||
lately." Offer it; don't make the human ask.
|
||||
- Reports are pure functions of the journal. If a number looks wrong, the
|
||||
|
||||
@@ -99,6 +99,102 @@ impl App {
|
||||
self.receipts.settle(ctx);
|
||||
}
|
||||
|
||||
/// Go where the agent asked (`books.show`).
|
||||
pub fn show(&mut self, target: ledger_store::viewer::ViewerTarget) {
|
||||
use crate::nav::ReportKind;
|
||||
use ledger_store::viewer::ViewerTarget as T;
|
||||
let Some(b) = self.books.clone() else {
|
||||
return;
|
||||
};
|
||||
let date = |s: &Option<String>| {
|
||||
s.as_deref()
|
||||
.and_then(|d| d.parse::<chrono::NaiveDate>().ok())
|
||||
};
|
||||
let route = match target {
|
||||
T::Page { page } => match page.as_str() {
|
||||
"overview" => Route::Overview,
|
||||
"cash" => Route::Cash,
|
||||
"profit" => Route::Profit,
|
||||
"transactions" => Route::Transactions,
|
||||
"reports" => Route::Reports(ReportKind::ProfitAndLoss),
|
||||
"accounts" => Route::Accounts,
|
||||
"documents" => Route::Documents,
|
||||
"company" => Route::Company,
|
||||
"journal" => Route::Journal,
|
||||
_ => return,
|
||||
},
|
||||
T::Entry { entry_id } => Route::Entry(entry_id),
|
||||
T::Account { code, start, end } => {
|
||||
let Some(a) = b.ledger.chart().find_by_code(&code) else {
|
||||
return;
|
||||
};
|
||||
self.state.accounts.range = match (date(&start), date(&end)) {
|
||||
(None, None) => None,
|
||||
(s, e) => Some((
|
||||
s.unwrap_or_else(|| b.kpis.first_entry.unwrap_or(b.today)),
|
||||
e.unwrap_or(b.today),
|
||||
)),
|
||||
};
|
||||
Route::Account(a.id)
|
||||
}
|
||||
T::Report {
|
||||
report,
|
||||
start,
|
||||
end,
|
||||
columns,
|
||||
compare,
|
||||
} => {
|
||||
use crate::pages::reports::{Columns, Compare};
|
||||
self.state.reports.columns = match columns.as_deref() {
|
||||
Some("month") => Columns::Month,
|
||||
Some("quarter") => Columns::Quarter,
|
||||
Some("year") => Columns::Year,
|
||||
_ => Columns::Total,
|
||||
};
|
||||
self.state.reports.compare = match compare.as_deref() {
|
||||
Some("prior_period") => Compare::PriorPeriod,
|
||||
Some("prior_year") => Compare::PriorYear,
|
||||
_ => Compare::None,
|
||||
};
|
||||
let kind = match report.as_str() {
|
||||
"profit_and_loss" => ReportKind::ProfitAndLoss,
|
||||
"balance_sheet" => ReportKind::BalanceSheet,
|
||||
"cash_flow" => ReportKind::CashFlow,
|
||||
"members_equity" => ReportKind::MembersEquity,
|
||||
"trial_balance" => ReportKind::TrialBalance,
|
||||
"general_ledger" => ReportKind::GeneralLedger,
|
||||
"tax_summary" => ReportKind::TaxSummary,
|
||||
"vendor_1099" => ReportKind::Vendor1099,
|
||||
"ar_aging" => ReportKind::ArAging,
|
||||
"ap_aging" => ReportKind::ApAging,
|
||||
_ => return,
|
||||
};
|
||||
if let (s, e) = (date(&start), date(&end))
|
||||
&& (s.is_some() || e.is_some())
|
||||
{
|
||||
let (ds, de) = crate::pages::reports::Range::YearToDate.dates(&b);
|
||||
self.state.reports.range =
|
||||
crate::pages::reports::Range::Custom(s.unwrap_or(ds), e.unwrap_or(de));
|
||||
}
|
||||
Route::Reports(kind)
|
||||
}
|
||||
T::Document { attachment_id } => Route::Document(attachment_id),
|
||||
T::Search { query } => {
|
||||
let st = &mut self.state.transactions;
|
||||
st.query = query;
|
||||
st.filter = Default::default();
|
||||
st.account = None;
|
||||
Route::Transactions
|
||||
}
|
||||
};
|
||||
self.nav.go(route);
|
||||
}
|
||||
|
||||
/// Where the app is (tests).
|
||||
pub fn route(&self) -> &Route {
|
||||
&self.nav.current
|
||||
}
|
||||
|
||||
/// Page state (tests).
|
||||
/// Label a fixed-books app "Live", as it reads when following a
|
||||
/// journal (screenshots of what a running app looks like).
|
||||
@@ -401,17 +497,18 @@ fn live_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool) {
|
||||
|
||||
impl eframe::App for App {
|
||||
fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) {
|
||||
if let Some(loader) = &self.loader {
|
||||
match loader.poll() {
|
||||
Some(Update::Books(b)) => {
|
||||
let updates = self.loader.as_ref().map(|l| l.poll()).unwrap_or_default();
|
||||
for u in updates {
|
||||
match u {
|
||||
Update::Books(b) => {
|
||||
if self.books.is_some() {
|
||||
self.updated_at = ui.input(|i| i.time);
|
||||
}
|
||||
self.books = Some(b);
|
||||
self.error = None;
|
||||
}
|
||||
Some(Update::Error(e)) => self.error = Some(e),
|
||||
None => {}
|
||||
Update::Error(e) => self.error = Some(e),
|
||||
Update::Show(target) => self.show(target),
|
||||
}
|
||||
}
|
||||
self.receipts.pump(ui.ctx());
|
||||
|
||||
@@ -30,6 +30,8 @@ pub struct Source {
|
||||
pub enum Update {
|
||||
Books(Arc<Books>),
|
||||
Error(String),
|
||||
/// The agent asked the viewer to show something (`books.show`).
|
||||
Show(ledger_store::viewer::ViewerTarget),
|
||||
}
|
||||
|
||||
pub struct Loader {
|
||||
@@ -46,13 +48,10 @@ impl Loader {
|
||||
Loader { rx }
|
||||
}
|
||||
|
||||
/// The newest update, if any arrived since the last call.
|
||||
pub fn poll(&self) -> Option<Update> {
|
||||
let mut last = None;
|
||||
while let Ok(u) = self.rx.try_recv() {
|
||||
last = Some(u);
|
||||
}
|
||||
last
|
||||
/// Every update since the last call, in order (books before the
|
||||
/// navigation that refers to them).
|
||||
pub fn poll(&self) -> Vec<Update> {
|
||||
self.rx.try_iter().collect()
|
||||
}
|
||||
|
||||
/// Block until the first update (tests, headless).
|
||||
@@ -121,6 +120,26 @@ fn run(source: Source, ctx: egui::Context, tx: Sender<Update>) {
|
||||
let mut last_disk = Some(disk);
|
||||
send(first);
|
||||
|
||||
// Requests to show something: apply new ones, and on start one made in
|
||||
// the last two minutes (the agent said "open the viewer"); never an
|
||||
// old one.
|
||||
let started = chrono::Utc::now();
|
||||
let mut last_show = 0u64;
|
||||
let mut check_show = |send: &dyn Fn(Update)| {
|
||||
if let Some(cmd) = ledger_store::viewer::read(&source.data_dir)
|
||||
&& cmd.seq != last_show
|
||||
{
|
||||
let first_look = last_show == 0;
|
||||
last_show = cmd.seq;
|
||||
let recent = chrono::DateTime::parse_from_rfc3339(&cmd.at)
|
||||
.is_ok_and(|t| started.signed_duration_since(t) < chrono::Duration::minutes(2));
|
||||
if !first_look || recent {
|
||||
send(Update::Show(cmd.target));
|
||||
}
|
||||
}
|
||||
};
|
||||
check_show(&send);
|
||||
|
||||
let (ev_tx, ev_rx) = channel();
|
||||
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
|
||||
if res.is_ok() {
|
||||
@@ -130,6 +149,11 @@ fn run(source: Source, ctx: egui::Context, tx: Sender<Update>) {
|
||||
.ok();
|
||||
if let Some(w) = watcher.as_mut() {
|
||||
let _ = w.watch(&source.data_dir, RecursiveMode::NonRecursive);
|
||||
let cmd = ledger_store::viewer::command_path(&source.data_dir);
|
||||
if let Some(dir) = cmd.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
let _ = w.watch(dir, RecursiveMode::NonRecursive);
|
||||
}
|
||||
if let Some(t) = &source.books_toml
|
||||
&& let Some(dir) = t.parent()
|
||||
{
|
||||
@@ -174,5 +198,6 @@ fn run(source: Source, ctx: egui::Context, tx: Sender<Update>) {
|
||||
last_disk = Some(disk);
|
||||
send(update);
|
||||
}
|
||||
check_show(&send);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ pub mod data;
|
||||
pub mod demo;
|
||||
mod fmt;
|
||||
pub mod nav;
|
||||
mod pages;
|
||||
pub mod pages;
|
||||
mod palette;
|
||||
mod receipts;
|
||||
mod theme;
|
||||
|
||||
@@ -64,5 +64,27 @@ fn a_change_in_another_process_shows_up_live() {
|
||||
assert!(b.fresh.contains(&e), "the new entry is highlighted as new");
|
||||
assert!(b.seq() > first.seq());
|
||||
eprintln!("live update arrived in {:?}", started.elapsed());
|
||||
|
||||
// The agent asks the viewer to show the entry (books.show).
|
||||
let asked = Instant::now();
|
||||
ledger_store::viewer::request(
|
||||
&data,
|
||||
ledger_store::viewer::ViewerTarget::Entry { entry_id: e },
|
||||
)
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let mut shown = false;
|
||||
while Instant::now() < deadline {
|
||||
if let Some(Update::Show(ledger_store::viewer::ViewerTarget::Entry { entry_id })) =
|
||||
loader.wait(Duration::from_millis(200))
|
||||
{
|
||||
assert_eq!(entry_id, e);
|
||||
shown = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(shown, "the show request never reached the viewer");
|
||||
eprintln!("show request arrived in {:?}", asked.elapsed());
|
||||
let _ = std::fs::remove_file(ledger_store::viewer::command_path(&data));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! books.show requests drive the viewer to the right place.
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use ledger_gui::nav::{ReportKind, Route};
|
||||
use ledger_gui::{App, Source, data};
|
||||
use ledger_store::viewer::ViewerTarget;
|
||||
|
||||
#[test]
|
||||
fn show_requests_navigate_the_viewer() {
|
||||
let today = chrono::NaiveDate::from_ymd_opt(2026, 10, 12).unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("ledger-gui-show-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
ledger_gui::demo::build(&dir, today).unwrap();
|
||||
let source = Source {
|
||||
data_dir: dir.join("books-data"),
|
||||
books_toml: Some(dir.join("wanderbooks.toml")),
|
||||
today: Some(today),
|
||||
};
|
||||
let books = data::load_once(&source).unwrap();
|
||||
let rent = books.ledger.chart().find_by_code("6200").unwrap().id;
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(1440.0, 920.0))
|
||||
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, true, Route::Overview));
|
||||
|
||||
// "Compare this quarter's P&L with the same quarter last year, by month."
|
||||
h.state_mut().show(ViewerTarget::Report {
|
||||
report: "profit_and_loss".into(),
|
||||
start: Some("2026-07-01".into()),
|
||||
end: Some("2026-09-30".into()),
|
||||
columns: Some("month".into()),
|
||||
compare: Some("prior_year".into()),
|
||||
});
|
||||
h.run_steps(2);
|
||||
assert_eq!(
|
||||
h.state_mut().route(),
|
||||
&Route::Reports(ReportKind::ProfitAndLoss)
|
||||
);
|
||||
let st = &h.state_mut().state_mut().reports;
|
||||
assert_eq!(
|
||||
st.range,
|
||||
ledger_gui::pages::reports::Range::Custom(
|
||||
chrono::NaiveDate::from_ymd_opt(2026, 7, 1).unwrap(),
|
||||
chrono::NaiveDate::from_ymd_opt(2026, 9, 30).unwrap()
|
||||
)
|
||||
);
|
||||
assert_eq!(st.columns, ledger_gui::pages::reports::Columns::Month);
|
||||
|
||||
// "Show me the rent account for the summer."
|
||||
h.state_mut().show(ViewerTarget::Account {
|
||||
code: "6200".into(),
|
||||
start: Some("2026-06-01".into()),
|
||||
end: Some("2026-08-31".into()),
|
||||
});
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state_mut().route(), &Route::Account(rent));
|
||||
|
||||
// "Find every Adobe charge."
|
||||
h.state_mut().show(ViewerTarget::Search {
|
||||
query: "adobe".into(),
|
||||
});
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state_mut().route(), &Route::Transactions);
|
||||
assert_eq!(h.state_mut().state_mut().transactions.query, "adobe");
|
||||
|
||||
// Unknown accounts are ignored, not crashes.
|
||||
h.state_mut().show(ViewerTarget::Account {
|
||||
code: "9999".into(),
|
||||
start: None,
|
||||
end: None,
|
||||
});
|
||||
assert_eq!(h.state_mut().route(), &Route::Transactions);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ use rust_decimal::Decimal;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// The agent operating contract, delivered via initialize.
|
||||
const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.\n- Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post.\n- 1099s: mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file; never store a TIN), and tag every payment to them with its vendor (books.draft_entry vendor, or books.set_entry_vendor; books.pay_bill tags automatically). books.vendor_1099 is the year-end worksheet for the CPA.";
|
||||
const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.\n- Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post.\n- The viewer: the human usually has the read-only viewer open beside you (`just gui`). When they ask to see something, and after you post, call books.show so it's on their screen.\n- 1099s: mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file; never store a TIN), and tag every payment to them with its vendor (books.draft_entry vendor, or books.set_entry_vendor; books.pay_bill tags automatically). books.vendor_1099 is the year-end worksheet for the CPA.";
|
||||
|
||||
/// Max receipt/document upload: 25 MB.
|
||||
const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
|
||||
@@ -1375,6 +1375,160 @@ impl BooksServer {
|
||||
Ok(Json(SearchEntriesOut { entries }))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.show",
|
||||
description = "Show something in the human's viewer (the read-only desktop app, `just gui`): an entry (with its lines, receipt, and history), an account's register (optionally for start/end dates), a report (optionally for start/end dates, split into month/quarter/year columns, compared with the prior period or prior year), a stored receipt or document, a search of the transactions, or a page. Give exactly one target; work out dates yourself (\"last quarter\" means its first and last day). Use it whenever the human asks to see something, and after posting so they can look at what you did. Changes nothing in the books.",
|
||||
annotations(read_only_hint = true)
|
||||
)]
|
||||
async fn show(&self, params: Parameters<ShowParams>) -> Result<Json<ShowOut>, String> {
|
||||
use ledger_store::viewer::{ViewerTarget, request};
|
||||
let p = params.0;
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
let given = [
|
||||
p.entry_id.is_some(),
|
||||
p.account.is_some(),
|
||||
p.report.is_some(),
|
||||
p.attachment_id.is_some(),
|
||||
p.search.is_some(),
|
||||
p.page.is_some(),
|
||||
]
|
||||
.iter()
|
||||
.filter(|x| **x)
|
||||
.count();
|
||||
if given != 1 {
|
||||
return Err(
|
||||
"give exactly one of entry_id, account, report, attachment_id, search, page".into(),
|
||||
);
|
||||
}
|
||||
for d in [&p.start, &p.end].into_iter().flatten() {
|
||||
parse_date(d)?;
|
||||
}
|
||||
const REPORTS: [&str; 10] = [
|
||||
"profit_and_loss",
|
||||
"balance_sheet",
|
||||
"cash_flow",
|
||||
"members_equity",
|
||||
"trial_balance",
|
||||
"general_ledger",
|
||||
"tax_summary",
|
||||
"vendor_1099",
|
||||
"ar_aging",
|
||||
"ap_aging",
|
||||
];
|
||||
const PAGES: [&str; 9] = [
|
||||
"overview",
|
||||
"cash",
|
||||
"profit",
|
||||
"transactions",
|
||||
"reports",
|
||||
"accounts",
|
||||
"documents",
|
||||
"company",
|
||||
"journal",
|
||||
];
|
||||
let (target, shown) = if let Some(id) = p.entry_id {
|
||||
let e = ledger
|
||||
.get_entry(id)
|
||||
.ok_or_else(|| format!("unknown entry {id}"))?;
|
||||
(
|
||||
ViewerTarget::Entry { entry_id: id },
|
||||
format!("entry #{id}, {}", e.description()),
|
||||
)
|
||||
} else if let Some(code) = p.account {
|
||||
let a = ledger
|
||||
.chart()
|
||||
.find_by_code(&code)
|
||||
.ok_or_else(|| format!("unknown account {code}"))?;
|
||||
(
|
||||
ViewerTarget::Account {
|
||||
code: code.clone(),
|
||||
start: p.start.clone(),
|
||||
end: p.end.clone(),
|
||||
},
|
||||
format!("the register of {} {}", a.code, a.name),
|
||||
)
|
||||
} else if let Some(report) = p.report {
|
||||
if !REPORTS.contains(&report.as_str()) {
|
||||
return Err(format!(
|
||||
"report {report:?}: expected one of {}",
|
||||
REPORTS.join(", ")
|
||||
));
|
||||
}
|
||||
if let Some(c) = &p.columns
|
||||
&& !["total", "month", "quarter", "year"].contains(&c.as_str())
|
||||
{
|
||||
return Err(format!(
|
||||
"columns {c:?}: expected total, month, quarter, or year"
|
||||
));
|
||||
}
|
||||
if let Some(c) = &p.compare
|
||||
&& !["prior_period", "prior_year"].contains(&c.as_str())
|
||||
{
|
||||
return Err(format!(
|
||||
"compare {c:?}: expected prior_period or prior_year"
|
||||
));
|
||||
}
|
||||
let mut what = format!("the {} report", report.replace('_', " "));
|
||||
if let (Some(s), Some(e)) = (&p.start, &p.end) {
|
||||
what.push_str(&format!(", {s} to {e}"));
|
||||
}
|
||||
if let Some(c) = p.columns.as_deref().filter(|c| *c != "total") {
|
||||
what.push_str(&format!(", by {c}"));
|
||||
}
|
||||
if let Some(c) = &p.compare {
|
||||
what.push_str(&format!(", compared with the {}", c.replace('_', " ")));
|
||||
}
|
||||
(
|
||||
ViewerTarget::Report {
|
||||
report: report.clone(),
|
||||
start: p.start.clone(),
|
||||
end: p.end.clone(),
|
||||
columns: p.columns.clone(),
|
||||
compare: p.compare.clone(),
|
||||
},
|
||||
what,
|
||||
)
|
||||
} else if let Some(id) = p.attachment_id {
|
||||
let a = store
|
||||
.list_attachments(None)
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.find(|a| a.id == id)
|
||||
.ok_or_else(|| format!("unknown attachment {id}"))?;
|
||||
(
|
||||
ViewerTarget::Document { attachment_id: id },
|
||||
format!("document {id}, {}", a.file_name),
|
||||
)
|
||||
} else if let Some(query) = p.search {
|
||||
(
|
||||
ViewerTarget::Search {
|
||||
query: query.clone(),
|
||||
},
|
||||
format!("transactions matching {query:?}"),
|
||||
)
|
||||
} else {
|
||||
let page = p.page.unwrap_or_default();
|
||||
if !PAGES.contains(&page.as_str()) {
|
||||
return Err(format!(
|
||||
"page {page:?}: expected one of {}",
|
||||
PAGES.join(", ")
|
||||
));
|
||||
}
|
||||
(
|
||||
ViewerTarget::Page { page: page.clone() },
|
||||
format!("the {page} page"),
|
||||
)
|
||||
};
|
||||
request(store.data_dir(), target).map_err(|e| format!("couldn't reach the viewer: {e}"))?;
|
||||
Ok(Json(ShowOut {
|
||||
shown: shown.clone(),
|
||||
note: format!(
|
||||
"Sent to the viewer: {shown}. If it isn't open, the human can open it with `just gui` (within a couple of minutes, it opens right there)."
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.verify",
|
||||
description = "Check the books end to end: the journal's hash chain; a fresh replay against the live ledger; every table of the SQLite database, row for row, against a fresh projection of the journal (rebuilding the database from the journal if it drifted, keeping the old file); and, by default, every stored receipt and document against its SHA-256. Run it whenever a number looks wrong or a human asks whether the books are intact."
|
||||
|
||||
@@ -404,6 +404,44 @@ pub struct VerifyOut {
|
||||
pub checked: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.show`. Give exactly one target.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct ShowParams {
|
||||
/// An entry to open (lines, receipt, history).
|
||||
pub entry_id: Option<u64>,
|
||||
/// An account code to open its register.
|
||||
pub account: Option<String>,
|
||||
/// A report: profit_and_loss, balance_sheet, cash_flow,
|
||||
/// members_equity, trial_balance, general_ledger, tax_summary,
|
||||
/// vendor_1099, ar_aging, ap_aging.
|
||||
pub report: Option<String>,
|
||||
/// A stored receipt or document (attachment id).
|
||||
pub attachment_id: Option<u64>,
|
||||
/// Search the transactions list (description, account, vendor, amount).
|
||||
pub search: Option<String>,
|
||||
/// A page: overview, cash, profit, transactions, reports, accounts,
|
||||
/// documents, company, journal.
|
||||
pub page: Option<String>,
|
||||
/// For account and report: first date, YYYY-MM-DD.
|
||||
pub start: Option<String>,
|
||||
/// For account and report: last date, YYYY-MM-DD.
|
||||
pub end: Option<String>,
|
||||
/// For a report: split into columns, "total" (default), "month",
|
||||
/// "quarter", or "year" (P&L, balance sheet, cash flow).
|
||||
pub columns: Option<String>,
|
||||
/// For a report: compare with "prior_period" or "prior_year".
|
||||
pub compare: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of `books.show`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ShowOut {
|
||||
/// What the viewer was asked to show.
|
||||
pub shown: String,
|
||||
/// Where this goes and what to tell the human.
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
/// Parameters for `books.chart_template`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct ChartTemplateParams {
|
||||
|
||||
@@ -139,7 +139,7 @@ fn mcp_end_to_end_over_stdio() {
|
||||
// Tool listing: 23 narrow tools, chart is read-only.
|
||||
let list = mcp.request("tools/list", json!({}));
|
||||
let tools = list["result"]["tools"].as_array().unwrap();
|
||||
assert_eq!(tools.len(), 65, "expected 65 tools, got {}", tools.len());
|
||||
assert_eq!(tools.len(), 66, "expected 66 tools, got {}", tools.len());
|
||||
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
|
||||
assert_eq!(chart["annotations"]["readOnlyHint"], true);
|
||||
assert!(
|
||||
@@ -1580,6 +1580,19 @@ fn mcp_vendor_1099_over_stdio() {
|
||||
assert_eq!(r["vendors"][0]["paid"], "0.00");
|
||||
let vendors = mcp.call("books.list_vendors", json!({}));
|
||||
assert_eq!(vendors["vendors"][0]["tin_on_file"], true);
|
||||
// Show the entry in the viewer: a request outside the books.
|
||||
let shown = mcp.call("books.show", json!({"entry_id": id}));
|
||||
assert!(shown["shown"].as_str().unwrap().contains(&format!("#{id}")));
|
||||
let cmd = ledger_store::viewer::read(&dir).expect("request written");
|
||||
assert_eq!(
|
||||
cmd.target,
|
||||
ledger_store::viewer::ViewerTarget::Entry { entry_id: id }
|
||||
);
|
||||
let err = mcp.call_err("books.show", json!({"entry_id": 9999}));
|
||||
assert!(err.contains("unknown entry"), "{err}");
|
||||
let err = mcp.call_err("books.show", json!({"page": "cash", "search": "x"}));
|
||||
assert!(err.contains("exactly one"), "{err}");
|
||||
let _ = std::fs::remove_file(ledger_store::viewer::command_path(&dir));
|
||||
// The books check out end to end, receipt included.
|
||||
let v = mcp.call("books.verify", json!({}));
|
||||
assert_eq!(v["status"], "ok");
|
||||
|
||||
@@ -24,6 +24,7 @@ pub mod journal;
|
||||
pub mod schema;
|
||||
pub mod snapshot;
|
||||
pub mod store;
|
||||
pub mod viewer;
|
||||
|
||||
pub use blob::BlobStore;
|
||||
pub use error::StoreError;
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Telling a running viewer what to show.
|
||||
//!
|
||||
//! The agent can ask the viewer (`ledger-gui`) to open an entry, an
|
||||
//! account, a report, a document, or a search. That's not bookkeeping, so
|
||||
//! it never touches the books: the request is a small JSON file outside the
|
||||
//! data directory, in the user's runtime directory, named for the books it
|
||||
//! belongs to. The viewer watches it and navigates; the server writes it.
|
||||
//! A request carries a sequence number so the same one never applies
|
||||
//! twice, and a time so a viewer started later ignores stale ones.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Where to go.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "show", rename_all = "snake_case")]
|
||||
pub enum ViewerTarget {
|
||||
/// A top-level page: overview, cash, profit, transactions, reports,
|
||||
/// accounts, documents, company, journal.
|
||||
Page {
|
||||
/// Page name.
|
||||
page: String,
|
||||
},
|
||||
/// One entry, with its lines, receipt, and history.
|
||||
Entry {
|
||||
/// Entry id.
|
||||
entry_id: u64,
|
||||
},
|
||||
/// An account's register, optionally for a date range.
|
||||
Account {
|
||||
/// Account code.
|
||||
code: String,
|
||||
/// First date, YYYY-MM-DD.
|
||||
start: Option<String>,
|
||||
/// Last date, YYYY-MM-DD.
|
||||
end: Option<String>,
|
||||
},
|
||||
/// A report, optionally for a date range.
|
||||
Report {
|
||||
/// profit_and_loss, balance_sheet, cash_flow, members_equity,
|
||||
/// trial_balance, general_ledger, tax_summary, vendor_1099,
|
||||
/// ar_aging, ap_aging.
|
||||
report: String,
|
||||
/// First date, YYYY-MM-DD.
|
||||
start: Option<String>,
|
||||
/// Last date, YYYY-MM-DD.
|
||||
end: Option<String>,
|
||||
/// Split into columns: total, month, quarter, or year.
|
||||
#[serde(default)]
|
||||
columns: Option<String>,
|
||||
/// Compare with: prior_period or prior_year.
|
||||
#[serde(default)]
|
||||
compare: Option<String>,
|
||||
},
|
||||
/// A stored receipt or document.
|
||||
Document {
|
||||
/// Attachment id.
|
||||
attachment_id: u64,
|
||||
},
|
||||
/// The transactions list, searched.
|
||||
Search {
|
||||
/// Search text: description, account, vendor, amount.
|
||||
query: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// A request for the viewer.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ViewerCommand {
|
||||
/// Increases with every request.
|
||||
pub seq: u64,
|
||||
/// When it was made, RFC 3339.
|
||||
pub at: String,
|
||||
/// Where to go.
|
||||
pub target: ViewerTarget,
|
||||
}
|
||||
|
||||
/// The command file for the books in `data_dir`: in `$XDG_RUNTIME_DIR`
|
||||
/// (or the temp dir), under `wanderbooks/`, named by a hash of the data
|
||||
/// directory's canonical path.
|
||||
pub fn command_path(data_dir: &Path) -> PathBuf {
|
||||
let canonical = data_dir
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| data_dir.to_path_buf());
|
||||
let key = crate::journal::sha256_hex(canonical.to_string_lossy().as_bytes());
|
||||
let base = std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(std::env::temp_dir);
|
||||
base.join("wanderbooks")
|
||||
.join(format!("viewer-{}.json", &key[..16]))
|
||||
}
|
||||
|
||||
/// Ask the viewer for `data_dir` to show `target`. Atomic (write then
|
||||
/// rename), so the viewer never reads half a request.
|
||||
pub fn request(data_dir: &Path, target: ViewerTarget) -> std::io::Result<ViewerCommand> {
|
||||
let path = command_path(data_dir);
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let seq = read(data_dir).map_or(1, |c| c.seq + 1);
|
||||
let cmd = ViewerCommand {
|
||||
seq,
|
||||
at: chrono::Utc::now().to_rfc3339(),
|
||||
target,
|
||||
};
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(
|
||||
&tmp,
|
||||
serde_json::to_vec(&cmd).map_err(std::io::Error::other)?,
|
||||
)?;
|
||||
std::fs::rename(&tmp, &path)?;
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
/// The latest request for `data_dir`, if any.
|
||||
pub fn read(data_dir: &Path) -> Option<ViewerCommand> {
|
||||
let bytes = std::fs::read(command_path(data_dir)).ok()?;
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn requests_round_trip_and_count_up() {
|
||||
let dir = std::env::temp_dir().join(format!("wb-viewer-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let a = request(&dir, ViewerTarget::Entry { entry_id: 7 }).unwrap();
|
||||
let b = request(
|
||||
&dir,
|
||||
ViewerTarget::Search {
|
||||
query: "adobe".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(b.seq, a.seq + 1);
|
||||
assert_eq!(
|
||||
read(&dir).unwrap().target,
|
||||
ViewerTarget::Search {
|
||||
query: "adobe".into()
|
||||
}
|
||||
);
|
||||
// Same books, same file; other books, another.
|
||||
assert_eq!(command_path(&dir), command_path(&dir.join(".")));
|
||||
assert_ne!(command_path(&dir), command_path(&std::env::temp_dir()));
|
||||
let _ = std::fs::remove_file(command_path(&dir));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user