Skip to content

Commit 8d66bb7

Browse files
Merge pull request #30 from PromptExecution/codex-issue-20-persist-mcp-state
feat: persist MCP operational state across restart
2 parents 648346b + 1b71544 commit 8d66bb7

38 files changed

Lines changed: 639 additions & 195 deletions

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,10 @@ Treat this as a standing operational gate, not a one-time migration task.
249249
- Treat `ledger_core::workbook::REQUIRED_SHEETS` as the canonical base workbook contract for export paths.
250250
- `export_cpa_workbook` should rebuild the full workbook from canonical service state on each export, including `META.config`, `ACCT.registry`, schedule sheets, flag sheets, transaction sheets, and `AUDIT.log`.
251251
- Tests should assert representative workbook contents, not just that a file was written.
252+
- 2026-04-17: restart-visible MCP operational state now persists as a deterministic sidecar next to the manifest workbook path.
253+
- Persist ingest idempotency state, transaction row cache, audit log, lifecycle event history, and HSM checkpoint together as one snapshot.
254+
- Keep the workbook as the human/accountant artifact; do not overload it as the only machine recovery mechanism for agent queues and replay state.
255+
- If the sidecar exists but cannot be parsed or its version is unsupported, fail closed instead of silently resetting state.
252256

253257

254258

crates/ledger-core/src/classify.rs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use std::path::Path;
22

33
use crate::ingest::{deterministic_tx_id, TransactionInput};
44
use rhai::{Dynamic, Engine, EvalAltResult, Map, Scope, AST};
5+
use serde::{Deserialize, Serialize};
56

67
#[derive(Debug, thiserror::Error)]
78
pub enum ClassificationError {
@@ -46,13 +47,13 @@ pub struct ClassificationBatch {
4647
pub classifications: Vec<ClassifiedTransaction>,
4748
}
4849

49-
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
5051
pub enum FlagStatus {
5152
Open,
5253
Resolved,
5354
}
5455

55-
#[derive(Debug, Clone, PartialEq)]
56+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
5657
pub struct ReviewFlag {
5758
pub tx_id: String,
5859
pub year: i32,
@@ -62,7 +63,7 @@ pub struct ReviewFlag {
6263
pub confidence: f64,
6364
}
6465

65-
#[derive(Debug, Default)]
66+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
6667
pub struct ClassificationEngine {
6768
flags: Vec<ReviewFlag>,
6869
}
@@ -200,10 +201,16 @@ fn run_classify_fn(
200201
fn sample_to_map(sample: &SampleTransaction) -> Map {
201202
let mut tx = Map::new();
202203
tx.insert("tx_id".into(), Dynamic::from(sample.tx_id.clone()));
203-
tx.insert("account_id".into(), Dynamic::from(sample.account_id.clone()));
204+
tx.insert(
205+
"account_id".into(),
206+
Dynamic::from(sample.account_id.clone()),
207+
);
204208
tx.insert("date".into(), Dynamic::from(sample.date.clone()));
205209
tx.insert("amount".into(), Dynamic::from(sample.amount.clone()));
206-
tx.insert("description".into(), Dynamic::from(sample.description.clone()));
210+
tx.insert(
211+
"description".into(),
212+
Dynamic::from(sample.description.clone()),
213+
);
207214
tx
208215
}
209216

crates/ledger-core/src/ingest.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ use std::path::Path;
33

44
use crate::journal::{append_entries, JournalTransaction};
55
use crate::workbook::{materialize_tx_projection, TxProjectionRow};
6+
use serde::{Deserialize, Serialize};
67

7-
#[derive(Debug, Clone, PartialEq, Eq)]
8+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89
pub struct TransactionInput {
910
pub account_id: String,
1011
pub date: String,
@@ -13,13 +14,13 @@ pub struct TransactionInput {
1314
pub source_ref: String,
1415
}
1516

16-
#[derive(Debug, Clone, PartialEq, Eq)]
17+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1718
pub struct IngestedTransaction {
1819
pub tx_id: String,
1920
pub source_ref: String,
2021
}
2122

22-
#[derive(Debug, Default)]
23+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2324
pub struct IngestedLedger {
2425
seen: BTreeSet<String>,
2526
projection_rows: Vec<TxProjectionRow>,

crates/ledger-core/src/journal.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,3 @@ fn invert_amount(amount: &str) -> String {
7575
format!("-{}", trimmed)
7676
}
7777
}
78-

crates/ledger-core/src/workbook.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::path::Path;
22

33
use rust_xlsxwriter::Workbook;
4+
use serde::{Deserialize, Serialize};
45

56
pub const REQUIRED_SHEETS: &[&str] = &[
67
"META.config",
@@ -23,7 +24,7 @@ pub fn initialize_workbook(path: &Path) -> Result<(), rust_xlsxwriter::XlsxError
2324
workbook.save(path)
2425
}
2526

26-
#[derive(Debug, Clone, PartialEq, Eq)]
27+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2728
pub struct TxProjectionRow {
2829
pub tx_id: String,
2930
pub account_id: String,

crates/ledgerr-mcp/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ license.workspace = true
77
[dependencies]
88
blake3 = "1.8.3"
99
ledger-core = { version = "=1.3.7", path = "../ledger-core" }
10-
rust_decimal = "1.41.0"
10+
rust_decimal = { version = "1.41.0", features = ["serde"] }
1111
rust_xlsxwriter = { workspace = true }
1212
schemars = { version = "0.8", features = ["derive"] }
1313
serde = { version = "1", features = ["derive"] }

crates/ledgerr-mcp/src/bin/ledgerr-mcp-server.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,12 @@ fn handle_request(request: Value) -> Option<Value> {
219219
fn global_service() -> &'static TurboLedgerService {
220220
static SERVICE: OnceLock<TurboLedgerService> = OnceLock::new();
221221
SERVICE.get_or_init(|| {
222-
let manifest = "[session]\nworkbook_path=\"tax-ledger.xlsx\"\nactive_year=2023\n\n[accounts]\nWF-BH-CHK = { institution = \"Wells Fargo\", type = \"checking\", currency = \"USD\" }\n";
223-
TurboLedgerService::from_manifest_str(manifest).expect("default manifest must parse")
222+
// Allow test/process callers to inject an explicit manifest so the stdio
223+
// transport can exercise restart persistence without sharing one global
224+
// relative workbook path across unrelated test processes.
225+
let manifest = std::env::var("LEDGERR_MCP_MANIFEST").unwrap_or_else(|_| {
226+
"[session]\nworkbook_path=\"tax-ledger.xlsx\"\nactive_year=2023\n\n[accounts]\nWF-BH-CHK = { institution = \"Wells Fargo\", type = \"checking\", currency = \"USD\" }\n".to_string()
227+
});
228+
TurboLedgerService::from_manifest_str(&manifest).expect("default manifest must parse")
224229
})
225230
}

crates/ledgerr-mcp/src/contract.rs

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -599,23 +599,23 @@ Expected blocked outcomes:\n\n\
599599

600600
pub fn generated_mcp_cli_demo_script() -> String {
601601
"#!/usr/bin/env bash\nset -euo pipefail\n\n\
602-
JOURNAL_PATH=\"${{JOURNAL_PATH:-/tmp/demo.beancount}}\"\n\
603-
WORKBOOK_PATH=\"${{WORKBOOK_PATH:-/tmp/demo.xlsx}}\"\n\
604-
SOURCE_REF=\"${{SOURCE_REF:-wf-2023-01.rkyv}}\"\n\n\
602+
JOURNAL_PATH=\"${JOURNAL_PATH:-/tmp/demo.beancount}\"\n\
603+
WORKBOOK_PATH=\"${WORKBOOK_PATH:-/tmp/demo.xlsx}\"\n\
604+
SOURCE_REF=\"${SOURCE_REF:-wf-2023-01.rkyv}\"\n\n\
605605
cargo run -q -p ledgerr-mcp --bin ledgerr-mcp-server <<EOF\n\
606-
{{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{{\"clientInfo\":{{\"name\":\"demo\",\"version\":\"0.1.0\"}}}}}}\n\
607-
{{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\",\"params\":{{}}}}\n\
608-
{{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{{}}}}\n\
609-
{{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{{\"name\":\"ledgerr_documents\",\"arguments\":{{\"action\":\"pipeline_status\"}}}}}}\n\
610-
{{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{{\"name\":\"ledgerr_documents\",\"arguments\":{{\"action\":\"list_accounts\"}}}}}}\n\
611-
{{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\",\"params\":{{\"name\":\"ledgerr_documents\",\"arguments\":{{\"action\":\"ingest_pdf\",\"pdf_path\":\"WF--BH-CHK--2023-01--statement.pdf\",\"journal_path\":\"$JOURNAL_PATH\",\"workbook_path\":\"$WORKBOOK_PATH\",\"raw_context_bytes\":[99,116,120],\"extracted_rows\":[{{\"account_id\":\"WF-BH-CHK\",\"date\":\"2023-01-15\",\"amount\":\"-42.11\",\"description\":\"Coffee Shop\",\"source_ref\":\"$SOURCE_REF\"}}]}}}}}}\n\
612-
{{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":\"tools/call\",\"params\":{{\"name\":\"ledgerr_documents\",\"arguments\":{{\"action\":\"get_raw_context\",\"rkyv_ref\":\"$SOURCE_REF\"}}}}}}\n\
606+
{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"clientInfo\":{\"name\":\"demo\",\"version\":\"0.1.0\"}}}\n\
607+
{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\",\"params\":{}}\n\
608+
{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n\
609+
{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"ledgerr_documents\",\"arguments\":{\"action\":\"pipeline_status\"}}}\n\
610+
{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"ledgerr_documents\",\"arguments\":{\"action\":\"list_accounts\"}}}\n\
611+
{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\",\"params\":{\"name\":\"ledgerr_documents\",\"arguments\":{\"action\":\"ingest_pdf\",\"pdf_path\":\"WF--BH-CHK--2023-01--statement.pdf\",\"journal_path\":\"$JOURNAL_PATH\",\"workbook_path\":\"$WORKBOOK_PATH\",\"raw_context_bytes\":[99,116,120],\"extracted_rows\":[{\"account_id\":\"WF-BH-CHK\",\"date\":\"2023-01-15\",\"amount\":\"-42.11\",\"description\":\"Coffee Shop\",\"source_ref\":\"$SOURCE_REF\"}]}}}\n\
612+
{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":\"tools/call\",\"params\":{\"name\":\"ledgerr_documents\",\"arguments\":{\"action\":\"get_raw_context\",\"rkyv_ref\":\"$SOURCE_REF\"}}}\n\
613613
EOF\n\n\
614614
# Troubleshooting path\n\
615615
cat <<'EOF'\n\
616-
{{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{{\"name\":\"ledgerr_workflow\",\"arguments\":{{\"action\":\"resume\",\"state_marker\":\"invalid-checkpoint\"}}}}}}\n\
617-
{{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{{\"name\":\"ledgerr_reconciliation\",\"arguments\":{{\"action\":\"commit\",\"source_total\":\"100.00\",\"extracted_total\":\"95.00\",\"posting_amounts\":[\"-95.00\",\"95.00\"]}}}}}}\n\
618-
{{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{{\"name\":\"ledgerr_audit\",\"arguments\":{{\"action\":\"event_history\",\"time_start\":\"2026-12-31\",\"time_end\":\"2026-01-01\"}}}}}}\n\
616+
{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"ledgerr_workflow\",\"arguments\":{\"action\":\"resume\",\"state_marker\":\"invalid-checkpoint\"}}}\n\
617+
{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"ledgerr_reconciliation\",\"arguments\":{\"action\":\"commit\",\"source_total\":\"100.00\",\"extracted_total\":\"95.00\",\"posting_amounts\":[\"-95.00\",\"95.00\"]}}}\n\
618+
{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"ledgerr_audit\",\"arguments\":{\"action\":\"event_history\",\"time_start\":\"2026-12-31\",\"time_end\":\"2026-01-01\"}}}\n\
619619
EOF\n"
620620
.to_string()
621621
}

crates/ledgerr-mcp/src/events.rs

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
use std::collections::BTreeMap;
22

33
use crate::ToolError;
4+
use serde::{Deserialize, Serialize};
45

56
const EVENT_TYPES: &[&str] = &["ingest", "classification", "reconciliation", "adjustment"];
67

7-
#[derive(Debug, Clone, PartialEq, Eq)]
8+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89
pub struct LifecycleEvent {
910
pub event_id: String,
1011
pub sequence: u64,
@@ -22,7 +23,7 @@ pub struct AppendEventResult {
2223
pub sequence: u64,
2324
}
2425

25-
#[derive(Debug, Clone, Default, PartialEq, Eq)]
26+
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2627
pub struct EventHistoryFilter {
2728
pub tx_id: Option<String>,
2829
pub document_ref: Option<String>,
@@ -54,7 +55,7 @@ pub trait LifecycleEventStore {
5455
fn list_events(&self, filter: EventHistoryFilter) -> Result<EventHistoryResponse, ToolError>;
5556
}
5657

57-
#[derive(Debug, Clone, Default)]
58+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
5859
pub struct InMemoryLifecycleEventStore {
5960
events: Vec<LifecycleEvent>,
6061
next_sequence: u64,
@@ -233,10 +234,7 @@ pub fn reconstruct_lifecycle(events: &[LifecycleEvent]) -> ReplayProjection {
233234
expected_sequence = expected_sequence.saturating_add(1);
234235
}
235236

236-
let tx_id = event
237-
.tx_id
238-
.clone()
239-
.unwrap_or_else(|| "_stream".to_string());
237+
let tx_id = event.tx_id.clone().unwrap_or_else(|| "_stream".to_string());
240238
let current_stage = stage_by_tx.get(&tx_id).cloned();
241239
if current_stage.is_none() && event.event_type != "ingest" {
242240
diagnostics.push(format!(

crates/ledgerr-mcp/src/hsm.rs

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1+
use serde::{Deserialize, Serialize};
2+
3+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
24
pub enum LifecycleState {
35
Ingest,
46
Normalize,
@@ -33,7 +35,7 @@ impl LifecycleState {
3335
}
3436
}
3537

36-
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
38+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
3739
pub enum LifecycleSubstate {
3840
Pending,
3941
Ready,
@@ -56,7 +58,7 @@ impl LifecycleSubstate {
5658
}
5759
}
5860

59-
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
61+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
6062
pub struct LifecycleNode {
6163
pub state: LifecycleState,
6264
pub substate: LifecycleSubstate,
@@ -110,7 +112,7 @@ pub struct HsmResumeResponse {
110112
pub blockers: Vec<String>,
111113
}
112114

113-
#[derive(Debug, Clone, PartialEq, Eq)]
115+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114116
pub struct HsmMachine {
115117
pub current: LifecycleNode,
116118
pub last_valid_checkpoint: String,
@@ -130,7 +132,11 @@ impl Default for HsmMachine {
130132
}
131133

132134
pub fn checkpoint_marker(node: LifecycleNode) -> String {
133-
format!("{}:{}:advanced", node.state.as_str(), node.substate.as_str())
135+
format!(
136+
"{}:{}:advanced",
137+
node.state.as_str(),
138+
node.substate.as_str()
139+
)
134140
}
135141

136142
pub fn parse_checkpoint_marker(marker: &str) -> Option<LifecycleNode> {
@@ -144,7 +150,11 @@ pub fn parse_checkpoint_marker(marker: &str) -> Option<LifecycleNode> {
144150
parse_node(state, substate)
145151
}
146152

147-
pub fn resume_response(node: LifecycleNode, resumed: bool, mut blockers: Vec<String>) -> HsmResumeResponse {
153+
pub fn resume_response(
154+
node: LifecycleNode,
155+
resumed: bool,
156+
mut blockers: Vec<String>,
157+
) -> HsmResumeResponse {
148158
blockers.sort();
149159
blockers.dedup();
150160
HsmResumeResponse {
@@ -195,7 +205,10 @@ pub fn next_hint_for(node: LifecycleNode) -> String {
195205
.to_string()
196206
}
197207

198-
pub fn transition_blocked_response(current: LifecycleNode, requested: LifecycleNode) -> HsmTransitionResponse {
208+
pub fn transition_blocked_response(
209+
current: LifecycleNode,
210+
requested: LifecycleNode,
211+
) -> HsmTransitionResponse {
199212
let mut transition_evidence = vec![
200213
format!("from={}", current.token()),
201214
format!("to={}", requested.token()),
@@ -212,7 +225,11 @@ pub fn transition_blocked_response(current: LifecycleNode, requested: LifecycleN
212225
status: "blocked".to_string(),
213226
guard_reason: Some("invalid_transition".to_string()),
214227
transition_evidence,
215-
state_marker: format!("{}:{}:blocked", current.state.as_str(), current.substate.as_str()),
228+
state_marker: format!(
229+
"{}:{}:blocked",
230+
current.state.as_str(),
231+
current.substate.as_str()
232+
),
216233
}
217234
}
218235

0 commit comments

Comments
 (0)