Skip to content

[codex] desktop host tray#32

Merged
elasticdotventures merged 4 commits into
mainfrom
codex-agents-context
Apr 18, 2026
Merged

[codex] desktop host tray#32
elasticdotventures merged 4 commits into
mainfrom
codex-agents-context

Conversation

@elasticdotventures
Copy link
Copy Markdown
Member

This PR adds the first desktop-host slice for l3dg3rr:

  • new ledgerr-host crate with notifier, settings, and tray modules
  • persistent per-user settings with tests
  • Windows toast validation via BurntToast / PowerShell fallback
  • tray icon runtime, menu state, version row, Exit action, and startup/exit toasts
  • minimal Slint host window binary launched from the tray
  • AGENTS and TODO guidance for successive agent runs

Validation:

  • cargo test -p ledgerr-host
  • Windows host build and launch of host-tray.exe
  • Windows host build and launch of host-window.exe

Notes:

  • The tray runtime currently uses a dedicated Windows pump thread so click/menu delivery can be verified and then refined.
  • The tray icon and window are intentionally split for this first slice to keep the Windows-host path debuggable.

Claude Sonnet (coordinator) and others added 2 commits April 18, 2026 12:29
… filesystem metadata

Adds the Financial Document Knowledge Management System foundation across
four new/updated crates: document model (DocType, XeroLink, DocumentRecord),
#tag newtypes, sidecar+xattr filesystem metadata backends, blocking Xero
PKCE OAuth2 client, blocking OpenAI-compatible LLM client (receipt/document
OCR + transaction classification), XeroService MCP bridge, and ledgerr_xero
as the 8th MCP tool. Regenerates docs and scripts from Rust contract source.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 18, 2026 05:28
Comment on lines +11 to +15
impl Default for SettingsSchemaVersion {
fn default() -> Self {
Self::V1
}
}
Comment on lines +11 to +15
impl Default for SettingsSchemaVersion {
fn default() -> Self {
Self::V1
}
}
store.save(&AppSettings::default()).unwrap();

let mut updated = AppSettings::default();
updated.toast_enabled = false;
.to_path_buf()
}

fn write(root: &PathBuf, rel: &str, content: &str) {
.to_path_buf()
}

fn write(root: &PathBuf, rel: &str, content: &str) {
Comment thread crates/ledgerr-mcp/src/lib.rs Outdated
Comment on lines +2427 to +2429
request.doc_type.as_deref().map_or(true, |dt| {
format!("{:?}", r.doc_type).to_ascii_lowercase().contains(dt)
})
Comment thread crates/ledgerr-mcp/src/lib.rs Outdated
Comment on lines +2427 to +2429
request.doc_type.as_deref().map_or(true, |dt| {
format!("{:?}", r.doc_type).to_ascii_lowercase().contains(dt)
})
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a first Windows desktop-host slice (ledgerr-host) with tray + notification + per-user settings, and also expands the MCP surface with new document-management actions plus a new ledgerr_xero tool (backed by a new ledgerr-xero crate) and an optional ledgerr-llm extraction crate.

Changes:

  • Add ledgerr-host (Windows tray runtime, PowerShell/BurntToast notifier, persistent JSON settings, minimal Slint window, tests).
  • Extend ledgerr-mcp contract + adapter to publish 8 tools (adds ledgerr_xero) and add new ledgerr_documents actions (image ingest, tagging, fs metadata sync, filename normalization) with document registry persistence.
  • Add new supporting crates/modules: ledgerr-xero, ledgerr-llm, plus ledger-core document/tags/fs-metadata primitives; update generated docs and MCP tests accordingly.

Reviewed changes

Copilot reviewed 50 out of 51 changed files in this pull request and generated 23 comments.

Show a summary per file
File Description
tax-ledger.xlsx.ledgerr-state.json Adds a sample persisted state sidecar file.
docs/mcp-capability-contract.md Updates generated MCP capability catalog (documents tool actions + adds Xero tool row).
docs/agent-mcp-runbook.md Updates generated runbook tool list to include ledgerr_xero.
crates/ledgerr-xero/src/types.rs Adds serde models for Xero API entities and response envelopes.
crates/ledgerr-xero/src/lib.rs Exposes ledgerr-xero public modules and re-exports.
crates/ledgerr-xero/src/error.rs Defines XeroError / XeroResult.
crates/ledgerr-xero/src/client.rs Implements blocking Xero HTTP client with token refresh.
crates/ledgerr-xero/src/auth.rs Implements blocking OAuth2 PKCE auth + token persistence.
crates/ledgerr-xero/Cargo.toml Introduces ledgerr-xero crate dependencies/features.
crates/ledgerr-mcp/tests/mcp_stdio_e2e.rs Updates tool-catalog assertions from 7 → 8.
crates/ledgerr-mcp/tests/mcp_adapter_contract.rs Updates tool-catalog contract tests and asserts ledgerr_xero presence.
crates/ledgerr-mcp/src/xero_service.rs Adds MCP-facing Xero service wrapper (feature-gated).
crates/ledgerr-mcp/src/ontology.rs Adds ontology entity kinds for Xero-linked entities + workflow tags.
crates/ledgerr-mcp/src/mcp_adapter.rs Wires in ledgerr_xero tool dispatch + adds new documents actions dispatch.
crates/ledgerr-mcp/src/lib.rs Adds document registry persistence + new document tools + Xero/LLM wiring in TurboLedgerService.
crates/ledgerr-mcp/src/contract.rs Expands published tool catalog to 8 and adds schemas/args for new documents actions + Xero tool.
crates/ledgerr-mcp/src/bin/regen-docs.rs Adds helper binary to regenerate docs/scripts from the Rust contract.
crates/ledgerr-mcp/src/bin/ledgerr-mcp-server.rs Routes ledgerr_xero tool calls to the adapter handler.
crates/ledgerr-mcp/Cargo.toml Adds optional deps/features for xero + llm and enables them by default.
crates/ledgerr-llm/src/lib.rs Adds blocking OpenAI-compatible client for vision + classification.
crates/ledgerr-llm/src/extract.rs Defines extraction structs and prompt templates.
crates/ledgerr-llm/src/error.rs Defines LlmError / LlmResult.
crates/ledgerr-llm/Cargo.toml Introduces ledgerr-llm crate dependencies.
crates/ledgerr-host/tests/tray_state.rs Adds unit tests for tray state defaults and menu labels.
crates/ledgerr-host/tests/settings_roundtrip.rs Adds settings load/save roundtrip and fallback tests.
crates/ledgerr-host/tests/settings_atomicity.rs Adds tests for parent-dir creation + “atomic” save behavior.
crates/ledgerr-host/src/tray/state.rs Defines tray state + command model.
crates/ledgerr-host/src/tray/runtime.rs Implements Windows tray runtime and command handling.
crates/ledgerr-host/src/tray/mod.rs Exposes tray modules and re-exports.
crates/ledgerr-host/src/tray/menu.rs Implements deterministic tray menu labels + tests.
crates/ledgerr-host/src/settings/store.rs Implements JSON settings store with temp-file rename.
crates/ledgerr-host/src/settings/schema.rs Defines settings schema and defaults.
crates/ledgerr-host/src/settings/path.rs Provides default per-user settings path selection.
crates/ledgerr-host/src/settings/mod.rs Exposes settings modules and re-exports.
crates/ledgerr-host/src/notify/types.rs Defines notifier types, events, and trait.
crates/ledgerr-host/src/notify/powershell.rs Adds PowerShell/BurntToast notifier implementation + tests.
crates/ledgerr-host/src/notify/mod.rs Exposes notify modules and re-exports.
crates/ledgerr-host/src/lib.rs Exposes notify, settings, and tray modules.
crates/ledgerr-host/src/bin/notify-test.rs Adds CLI tool to test notifications and persist results.
crates/ledgerr-host/src/bin/host-window.rs Adds minimal Windows-only Slint host window.
crates/ledgerr-host/src/bin/host-tray.rs Adds Windows-only tray host binary entrypoint.
crates/ledgerr-host/Cargo.toml Introduces ledgerr-host crate + Windows-only UI/tray deps.
crates/ledger-core/src/tags.rs Adds validated workflow tag model + well-known tags.
crates/ledger-core/src/lib.rs Exposes new document, fs_meta, and tags modules.
crates/ledger-core/src/fs_meta.rs Adds filesystem sidecar (+ Linux xattr) metadata read/write/scan support.
crates/ledger-core/src/document.rs Adds document model, doc types, Xero link model, and content-addressed doc IDs.
crates/ledger-core/Cargo.toml Adds serde_json and Linux-only xattr dep for fs metadata.
TODO.md Adds phased TODO plan for notifier/settings/tray/window work.
Cargo.toml Adds new workspace members and shared workspace dependencies.
AGENTS.md Adds updated operator direction and execution loop guidance.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread crates/ledgerr-mcp/src/lib.rs Outdated
Comment on lines +2114 to +2118
std::fs::read_to_string(&path)
.ok()
.and_then(|raw| serde_json::from_str(&raw).ok())
.unwrap_or_default()
}
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

load_document_registry silently falls back to an empty registry on read/parse errors. That can cause quiet state loss if the file is corrupted or partially written. Consider returning an error (or at least logging + preserving the unreadable file) so operators can recover instead of starting from an empty registry.

Copilot uses AI. Check for mistakes.
Comment on lines +2505 to +2508
if request.apply && path.exists() {
let new_path = path.with_file_name(&proposed_name);
std::fs::rename(path, &new_path)
.map_err(|e| ToolError::Internal(e.to_string()))?;
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When apply is true, this will rename whatever file_path points at, with no allowed-base restriction (unlike ingest_pdf). Consider constraining renames to a safe base directory (e.g. self.workbook_path().parent()) and rejecting paths outside it.

Copilot uses AI. Check for mistakes.

[features]
default = ["core", "events", "reconciliation", "hsm", "ontology", "classification", "audit", "tax"]
default = ["core", "events", "reconciliation", "hsm", "ontology", "classification", "audit", "tax", "xero", "llm"]
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR is titled/described as a desktop host tray slice, but this change also expands/enables new MCP capabilities by default (notably xero + llm). If that scope increase is intended, please update the PR description to call it out explicitly (or split into separate PRs) so reviewers can evaluate risk appropriately.

Copilot uses AI. Check for mistakes.
Comment thread crates/ledger-core/src/tags.rs Outdated
Comment on lines +43 to +50
/// Infallible parse that prefixes '#' if missing and normalizes.
pub fn normalize(raw: &str) -> Self {
let s = if raw.starts_with('#') {
raw.to_string()
} else {
format!("#{raw}")
};
Self::new(&s).unwrap_or_else(|_| Self(s.to_ascii_lowercase()))
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tag::normalize falls back to Self(s.to_ascii_lowercase()) on validation failure, which can create Tag values containing invalid characters (spaces/symbols) despite Tag being documented as validated. Consider sanitizing into a valid tag (e.g., replace invalid chars) or make normalize return Result<Tag, TagError> to preserve the invariant.

Suggested change
/// Infallible parse that prefixes '#' if missing and normalizes.
pub fn normalize(raw: &str) -> Self {
let s = if raw.starts_with('#') {
raw.to_string()
} else {
format!("#{raw}")
};
Self::new(&s).unwrap_or_else(|_| Self(s.to_ascii_lowercase()))
fn sanitize_body(body: &str) -> String {
let mut sanitized = String::new();
let mut last_was_hyphen = false;
for c in body.trim().chars() {
let mapped = if c.is_ascii_alphanumeric() {
Some(c.to_ascii_lowercase())
} else if c == '-' || c.is_ascii_whitespace() {
Some('-')
} else {
Some('-')
};
if let Some(ch) = mapped {
if ch == '-' {
if !sanitized.is_empty() && !last_was_hyphen {
sanitized.push(ch);
last_was_hyphen = true;
}
} else {
sanitized.push(ch);
last_was_hyphen = false;
}
}
}
let sanitized = sanitized.trim_matches('-').to_string();
if sanitized.is_empty() {
"tag".to_string()
} else {
sanitized
}
}
/// Infallible parse that prefixes '#' if missing and normalizes.
pub fn normalize(raw: &str) -> Self {
let s = raw.trim();
let s = if s.starts_with('#') {
s.to_string()
} else {
format!("#{s}")
};
Self::new(&s).unwrap_or_else(|_| {
let body = s.strip_prefix('#').unwrap_or(&s);
Self(format!("#{}", Self::sanitize_body(body)))
})

Copilot uses AI. Check for mistakes.
Comment on lines +2499 to +2502
let proposed_name = format!(
"{}--{}--{}--{}.{}",
vendor, account, request.year_month, request.doc_type, ext
);
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

proposed_name is constructed from user-supplied strings without sanitizing path separators / traversal sequences. Reject or sanitize characters like /, \, and .. in vendor/account/year_month/doc_type so the output is always a safe filename and can’t escape the intended directory.

Copilot uses AI. Check for mistakes.
Comment thread tax-ledger.xlsx.ledgerr-state.json Outdated
Comment on lines +14 to +15
"source_ref": "/tmp/l3dg3rr-outcome-flow-161024-1776478923653/WF--BH-CHK--2023-01--statement.rkyv"
}
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This persisted state sidecar appears to be a generated local artifact and includes machine-specific absolute paths under /tmp. It shouldn’t be committed; please remove it from the PR and add an ignore rule (e.g. *.ledgerr-state.json) so local runs don’t dirty the repo or leak local paths.

Copilot uses AI. Check for mistakes.
Comment on lines +8 to +12
pub struct XeroContact {
pub contact_i_d: String,
pub name: String,
#[serde(default)]
pub email_address: Option<String>,
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Public Xero types use *_i_d field names to hit PascalCase*ID serde mapping. This leaks an awkward naming convention into the public API; consider using idiomatic Rust names (e.g. contact_id) with explicit #[serde(rename = "ContactID")] instead.

Copilot uses AI. Check for mistakes.
Comment on lines +2411 to +2413
let filter_tags: Vec<String> = request.tags.iter()
.map(|t| Tag::normalize(t).as_str().to_string())
.collect();
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

list_tagged_tool never uses request.directory, even though the contract includes it. Either implement directory scoping (e.g. only include records whose file_path is under the provided directory) or remove the parameter from the API to avoid misleading callers.

Copilot uses AI. Check for mistakes.
Comment thread crates/ledgerr-mcp/Cargo.toml Outdated
Comment on lines +19 to +20
tokio = { workspace = true, optional = true }
tracing = { workspace = true }
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tokio is added as an optional dependency (and pulled in by the xero/llm features), but there are no tokio:: usages in this crate (or in the new ledgerr-xero/ledgerr-llm crates). Unless there’s a near-term async follow-up, consider removing this to avoid unnecessary compile time and transitive deps.

Copilot uses AI. Check for mistakes.
Comment thread crates/ledgerr-host/src/tray/runtime.rs Outdated
Comment on lines +213 to +236
let settings = store.load()?;
let notify_settings = NotificationSettings {
enabled: settings.toast_enabled,
backend: settings.toast_backend_preference,
last_test_result: settings.last_test_result.clone(),
};
let notifier = PowerShellBurntToastNotifier::new(notify_settings);
let result = notifier.test("l3dg3rr", "tray test toast");

let mut settings = settings;
let mut state = state.lock().expect("tray state poisoned");
match result {
Ok(test_result) => {
settings.last_test_result = Some(test_result);
settings.toast_backend_preference = NotificationBackend::PowerShell;
store.save(&settings)?;
state.notification_status = if state.toast_enabled {
crate::notify::NotificationStatus::Ready
} else {
crate::notify::NotificationStatus::Disabled
};
}
Err(_) => {
state.notification_status = crate::notify::NotificationStatus::Failed;
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even when settings request NotificationBackend::Noop or Auto, the tray runtime always constructs a PowerShellBurntToastNotifier and attempts to send a PowerShell toast. Consider routing through a backend-selection layer that respects toast_backend_preference (and uses a real noop backend when configured).

Suggested change
let settings = store.load()?;
let notify_settings = NotificationSettings {
enabled: settings.toast_enabled,
backend: settings.toast_backend_preference,
last_test_result: settings.last_test_result.clone(),
};
let notifier = PowerShellBurntToastNotifier::new(notify_settings);
let result = notifier.test("l3dg3rr", "tray test toast");
let mut settings = settings;
let mut state = state.lock().expect("tray state poisoned");
match result {
Ok(test_result) => {
settings.last_test_result = Some(test_result);
settings.toast_backend_preference = NotificationBackend::PowerShell;
store.save(&settings)?;
state.notification_status = if state.toast_enabled {
crate::notify::NotificationStatus::Ready
} else {
crate::notify::NotificationStatus::Disabled
};
}
Err(_) => {
state.notification_status = crate::notify::NotificationStatus::Failed;
let mut settings = store.load()?;
let backend = settings.toast_backend_preference;
let notify_settings = NotificationSettings {
enabled: settings.toast_enabled,
backend,
last_test_result: settings.last_test_result.clone(),
};
let mut state = state.lock().expect("tray state poisoned");
match backend {
NotificationBackend::Noop => {
state.notification_status = crate::notify::NotificationStatus::Disabled;
}
NotificationBackend::PowerShell | NotificationBackend::Auto => {
let notifier = PowerShellBurntToastNotifier::new(notify_settings);
match notifier.test("l3dg3rr", "tray test toast") {
Ok(test_result) => {
settings.last_test_result = Some(test_result);
store.save(&settings)?;
state.notification_status = if state.toast_enabled {
crate::notify::NotificationStatus::Ready
} else {
crate::notify::NotificationStatus::Disabled
};
}
Err(_) => {
state.notification_status =
crate::notify::NotificationStatus::Failed;
}
}

Copilot uses AI. Check for mistakes.
@elasticdotventures
Copy link
Copy Markdown
Member Author

@copilot apply changes based on the comments in this thread

Copilot AI and others added 2 commits April 18, 2026 12:36
…eview comments

Agent-Logs-Url: https://github.com/PromptExecution/l3dg3rr/sessions/71b99866-787b-4851-9f7d-7389a02da586

Co-authored-by: elasticdotventures <35611074+elasticdotventures@users.noreply.github.com>
… variable names in sanitize_body

Agent-Logs-Url: https://github.com/PromptExecution/l3dg3rr/sessions/71b99866-787b-4851-9f7d-7389a02da586

Co-authored-by: elasticdotventures <35611074+elasticdotventures@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 18, 2026

@copilot apply changes based on the comments in this thread

Applied all changes from the PR review thread in commits 1a6fa94 and f308d56. Summary of what was addressed:

Clippy fixes:

  • SettingsSchemaVersion now derives Default with #[default] on V1
  • settings_atomicity.rs: field assignment replaced with struct update syntax
  • regen-docs.rs: &PathBuf&Path
  • lib.rs: map_or(true, ...) simplified to is_none_or

Security / path traversal:

  • ingest_image_tool, sync_fs_metadata_tool, normalize_filename_tool now apply the same allowed-base guardrails as ingest_pdf (reject .. components, absolute paths must start with workbook_path.parent())
  • normalize_filename_tool component sanitizer uses Component::ParentDir check (not substring match)
  • search_contacts escapes double-quotes in the Xero filter expression

Error handling:

  • load_document_registry logs tracing::warn on read/parse failure instead of silently resetting
  • save_document_registry errors now propagated (not silently ignored) in ingest_image, apply_tags, sync_fs_metadata
  • extract_with_llm = true returns ToolError when llm feature is disabled or backend is None
  • fetch_tenant_blocking error is propagated instead of unwrap_or_default()

Xero OAuth:

  • get_auth_url now stores the new client (with PKCE state) in self.client so exchange_code uses the same pending flow

Xero types: Idiomatic Rust field names (contact_id, account_id, invoice_id) with explicit #[serde(rename = "ContactID")] etc.

Tray:

  • TestToast no longer overwrites toast_backend_preference on success
  • Noop backend is respected — PowerShell notifier is not constructed when Noop is configured

Other:

  • list_tagged_tool implements request.directory scoping
  • Settings save() does remove-before-rename on Windows for atomic overwrites
  • Removed unused tokio optional dep from ledgerr-mcp
  • Removed committed tax-ledger.xlsx.ledgerr-state.json; added *.ledgerr-state.json to .gitignore
  • Docs updated to "8-tool catalog" throughout (contract.rs, mcp-capability-contract.md, agent-mcp-runbook.md)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants