P2 slice 6: the viewer
`wanderbooks gui [BOOK]` finds the data root, reads settings from the journal, the vault of the book, presence through ops/, Save a copy / Open on every document, Save the W-9, the Export menu, and colour schemes. One builder for the CPA package and the 1099 worksheet (ledger-package), shared by the server and the viewer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
1 parent
b26c099a68
commit
5bc0633cdd
77 files changed
+5833
-2427
No files matched your search
@@ -0,0 +1,307 @@
|
||||
//! The viewer's side channel (plan 3.6): what the agent asks the viewer
|
||||
//! to show (`books.show`), and whether a viewer has the books open. Not
|
||||
//! bookkeeping, so never journaled, but kept with the books, in their own
|
||||
//! `ops/` folder: the server writes; the viewer, which never writes, reads.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: the agent can move a viewer that has the books open, and know
|
||||
//! whether one has, without the viewer writing anything.
|
||||
//! SOURCE: plan 3.6 ("the server appends {seq, ts, target} to
|
||||
//! ops/show.jsonl (bounded, rotated, not journaled)"; "the GUI holds
|
||||
//! a shared flock on ops/viewer.lock, opened read-only. The server
|
||||
//! probes the lock to report viewer.open").
|
||||
//! INVARIANTS:
|
||||
//! 1. A show request is a line of `ops/show.jsonl`, `{seq, at, target}`,
|
||||
//! its seq one more than the last; the file holds at most KEPT lines
|
||||
//! and is replaced whole (written aside, renamed), so a reader sees
|
||||
//! the old file or the new one, each line whole.
|
||||
//! 2. `ops/` (0700) and `ops/viewer.lock` (0600) are made by the server
|
||||
//! (`prepare`), never by the viewer; a request only navigates, so a
|
||||
//! stray writer can do no more than move the view.
|
||||
//! 3. A viewer is open while some process holds a shared lock on
|
||||
//! `ops/viewer.lock`; the viewer takes it through a read-only
|
||||
//! descriptor, and `viewer_open` probes it without taking it.
|
||||
//! ```
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The books' side-channel folder, in their own folder.
|
||||
pub const OPS_DIR: &str = "ops";
|
||||
/// The show requests (INVARIANT 1).
|
||||
pub const SHOW_FILE: &str = "show.jsonl";
|
||||
/// The viewer's presence lock (INVARIANT 3).
|
||||
pub const VIEWER_LOCK: &str = "viewer.lock";
|
||||
/// The most show requests kept.
|
||||
pub const KEPT: usize = 32;
|
||||
|
||||
/// 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, assets, parties, 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, fixed_asset_register.
|
||||
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,
|
||||
},
|
||||
/// One fixed asset: its identity, schedule, entries, and documents.
|
||||
Asset {
|
||||
/// Asset id.
|
||||
asset_id: u64,
|
||||
},
|
||||
/// One party: who they are, their roles, and everything the company
|
||||
/// did with them.
|
||||
Party {
|
||||
/// Party id.
|
||||
party_id: u64,
|
||||
},
|
||||
/// One reconciliation: its account, with the account's statements and
|
||||
/// reconciliations.
|
||||
Reconciliation {
|
||||
/// Reconciliation id.
|
||||
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 `ops/` folder of the books in `data_dir`.
|
||||
pub fn ops_dir(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join(OPS_DIR)
|
||||
}
|
||||
|
||||
/// Make the books' `ops/` folder and the viewer's lock (INVARIANT 2): the
|
||||
/// server does, when it opens the books and before it writes a request.
|
||||
pub fn prepare(data_dir: &Path) -> std::io::Result<()> {
|
||||
let dir = crate::root::private_book_folder(data_dir, OPS_DIR)
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
let lock = dir.join(VIEWER_LOCK);
|
||||
let mut o = std::fs::OpenOptions::new();
|
||||
o.write(true).create(true).truncate(false);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
o.mode(0o600);
|
||||
}
|
||||
o.open(&lock)?;
|
||||
// Not the books: kept out of a books dir's version history (an older
|
||||
// books dir lives in git), by a rule in the folder itself.
|
||||
let ignore = dir.join(".gitignore");
|
||||
if !ignore.exists() {
|
||||
std::fs::write(&ignore, "# The viewer's side channel: not the books.\n*\n")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every request kept, oldest first; lines that aren't whole requests are
|
||||
/// skipped.
|
||||
pub fn requests(data_dir: &Path) -> Vec<ViewerCommand> {
|
||||
std::fs::read_to_string(ops_dir(data_dir).join(SHOW_FILE))
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.filter_map(|l| serde_json::from_str(l).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The latest request for the books in `data_dir`, if any.
|
||||
pub fn read(data_dir: &Path) -> Option<ViewerCommand> {
|
||||
requests(data_dir).pop()
|
||||
}
|
||||
|
||||
/// Ask the viewer of the books in `data_dir` to show `target`
|
||||
/// (INVARIANT 1).
|
||||
pub fn request(data_dir: &Path, target: ViewerTarget) -> std::io::Result<ViewerCommand> {
|
||||
prepare(data_dir)?;
|
||||
let mut kept = requests(data_dir);
|
||||
let cmd = ViewerCommand {
|
||||
seq: kept.last().map_or(1, |c| c.seq + 1),
|
||||
at: chrono::Utc::now().to_rfc3339(),
|
||||
target,
|
||||
};
|
||||
kept.push(cmd.clone());
|
||||
let from = kept.len().saturating_sub(KEPT);
|
||||
let mut text = String::new();
|
||||
for c in &kept[from..] {
|
||||
text.push_str(&serde_json::to_string(c).map_err(std::io::Error::other)?);
|
||||
text.push('\n');
|
||||
}
|
||||
let dir = ops_dir(data_dir);
|
||||
let aside = dir.join(format!(".{SHOW_FILE}.new"));
|
||||
let _ = std::fs::remove_file(&aside);
|
||||
let mut o = std::fs::OpenOptions::new();
|
||||
o.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
o.mode(0o600);
|
||||
}
|
||||
let mut f = o.open(&aside)?;
|
||||
f.write_all(text.as_bytes())?;
|
||||
f.sync_all()?;
|
||||
drop(f);
|
||||
std::fs::rename(&aside, dir.join(SHOW_FILE))?;
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
/// A viewer's presence: a shared lock on `ops/viewer.lock`, through a
|
||||
/// read-only descriptor, held while this lives (INVARIANT 3).
|
||||
#[derive(Debug)]
|
||||
pub struct Presence(#[allow(dead_code)] std::fs::File);
|
||||
|
||||
impl Presence {
|
||||
/// Take it for the books in `data_dir`, if the server made the lock
|
||||
/// (None until it has: the viewer makes nothing).
|
||||
pub fn hold(data_dir: &Path) -> Option<Presence> {
|
||||
let f = std::fs::File::open(ops_dir(data_dir).join(VIEWER_LOCK)).ok()?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::fd::AsRawFd;
|
||||
// SAFETY: flock on a descriptor this function owns.
|
||||
if unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_SH | libc::LOCK_NB) } != 0 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(Presence(f))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a viewer has the books in `data_dir` open (INVARIANT 3): some
|
||||
/// process holds a shared lock on their `ops/viewer.lock`.
|
||||
pub fn viewer_open(data_dir: &Path) -> bool {
|
||||
let Ok(f) = std::fs::File::open(ops_dir(data_dir).join(VIEWER_LOCK)) else {
|
||||
return false;
|
||||
};
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::fd::AsRawFd;
|
||||
// SAFETY: flock on a descriptor this function owns; a lock taken
|
||||
// is let go at once (and with the descriptor anyway).
|
||||
let free = unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0;
|
||||
if free {
|
||||
// SAFETY: as above.
|
||||
unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_UN) };
|
||||
}
|
||||
!free
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
drop(f);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn requests_count_up_and_only_so_many_are_kept() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(read(dir.path()), None);
|
||||
let a = request(dir.path(), ViewerTarget::Entry { entry_id: 7 }).unwrap();
|
||||
let b = request(
|
||||
dir.path(),
|
||||
ViewerTarget::Search {
|
||||
query: "adobe".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((a.seq, b.seq), (1, 2));
|
||||
assert_eq!(read(dir.path()), Some(b));
|
||||
for n in 0..(KEPT as u64 + 5) {
|
||||
request(dir.path(), ViewerTarget::Entry { entry_id: n }).unwrap();
|
||||
}
|
||||
let kept = requests(dir.path());
|
||||
assert_eq!(kept.len(), KEPT);
|
||||
assert_eq!(kept.last().unwrap().seq, 2 + KEPT as u64 + 5);
|
||||
assert!(kept.windows(2).all(|w| w[1].seq == w[0].seq + 1));
|
||||
// A torn line (an old writer's) is skipped, never the whole file.
|
||||
let file = ops_dir(dir.path()).join(SHOW_FILE);
|
||||
let mut text = std::fs::read_to_string(&file).unwrap();
|
||||
text.push_str("{\"seq\": 99, \"at\"");
|
||||
std::fs::write(&file, text).unwrap();
|
||||
assert_eq!(requests(dir.path()).len(), KEPT);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_server_makes_the_folder_and_the_viewer_only_reads_it() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// Nothing made: no presence, and taking it makes nothing.
|
||||
assert!(Presence::hold(dir.path()).is_none());
|
||||
assert!(!ops_dir(dir.path()).exists());
|
||||
assert!(!viewer_open(dir.path()));
|
||||
prepare(dir.path()).unwrap();
|
||||
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode(&ops_dir(dir.path())), 0o700);
|
||||
assert_eq!(mode(&ops_dir(dir.path()).join(VIEWER_LOCK)), 0o600);
|
||||
assert!(!viewer_open(dir.path()));
|
||||
let held = Presence::hold(dir.path()).unwrap();
|
||||
assert!(viewer_open(dir.path()));
|
||||
// Two viewers at once share it.
|
||||
let second = Presence::hold(dir.path()).unwrap();
|
||||
drop(held);
|
||||
assert!(viewer_open(dir.path()));
|
||||
drop(second);
|
||||
assert!(!viewer_open(dir.path()));
|
||||
// Made again, nothing changes.
|
||||
prepare(dir.path()).unwrap();
|
||||
assert!(!viewer_open(dir.path()));
|
||||
// Kept out of a books dir's version history by a rule of its own.
|
||||
let rule = std::fs::read_to_string(ops_dir(dir.path()).join(".gitignore")).unwrap();
|
||||
assert!(rule.lines().any(|l| l == "*"), "{rule}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user