diff --git a/Cargo.toml b/Cargo.toml index 297e9c11d..721970e88 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.8.48" +version = "0.8.49" edition = "2024" publish = false @@ -26,7 +26,7 @@ agency-proxy-protocol = "^0.1.8" # The GUI and both storage tools compile the same schema against one resolved # WorkTable package. A workspace dependency prevents their compatible ranges # from drifting into separate copies without freezing the selected patch. -worktable = "1.0.0-beta.11" +worktable = "1.0.0-beta.17" # Heavy deps are declared per-crate on purpose: only apps/gui pulls in the # Tauri stack, so agent/proxy builds never trigger a webview toolchain build. diff --git a/apps/gui/Cargo.toml b/apps/gui/Cargo.toml index 53dc7c24c..32c1ce0ce 100644 --- a/apps/gui/Cargo.toml +++ b/apps/gui/Cargo.toml @@ -69,6 +69,10 @@ agency-proxy-protocol.workspace = true agent-experimental = { version = "^0.1.3", default-features = false, optional = true } # Incremental authoring parsing and strict inertness diagnostics ship upstream. promptsyntax = "0.2.0" +# This workspace deliberately resolves without a lockfile. tinyvec 1.13.0 +# fails its no_std heap branch because the vec macro is not imported, so keep +# the compatible transitive graph on the last compiling release. +tinyvec = "=1.12.0" shlex = "1.3" az-core.workspace = true # Shared with the migration and headless tools so one schema cannot resolve diff --git a/apps/gui/src/db/tables.rs b/apps/gui/src/db/tables.rs index 72936bdb2..dcf3d075c 100644 --- a/apps/gui/src/db/tables.rs +++ b/apps/gui/src/db/tables.rs @@ -158,19 +158,18 @@ impl Tables { /// Failing here is deliberate: running with no persistence would let every /// write appear to succeed and vanish on the next launch, which is a worse /// failure than refusing to start. - pub async fn open(dir: &Path) -> Result> { + pub async fn open(dir: &Path) -> eyre::Result { let open_file_limit = raise_open_file_limit()?; if open_file_limit < 356 { - return Err(format!( + return Err(eyre::eyre!( "open-file limit {open_file_limit} leaves fewer than 100 descriptors above the 256-descriptor failure boundary" - ) - .into()); + )); } let open_store_permit = open_store_gate() .clone() .acquire_owned() .await - .map_err(|_| "the full-store descriptor gate closed")?; + .map_err(|_| eyre::eyre!("the full-store descriptor gate closed"))?; std::fs::create_dir_all(dir)?; let data_dir = dir.to_path_buf(); let dir = dir.to_string_lossy().to_string(); @@ -709,6 +708,7 @@ mod restart_tests { dismissed: false, updated_at: "initial".into(), }) + .await .expect("should insert"); tables .pull_request @@ -788,6 +788,7 @@ mod restart_tests { tables .pull_request .insert(row.clone()) + .await .expect("first insert"); tables .pull_request @@ -796,7 +797,7 @@ mod restart_tests { .expect("first insert should drain"); assert!( - tables.pull_request.insert(row).is_err(), + tables.pull_request.insert(row).await.is_err(), "the duplicate should be rejected" ); tables @@ -862,7 +863,7 @@ mod restart_tests { body: format!("reply {n}, already on screen"), created_at: format!("2026-07-31T00:00:{:02}Z", n % 60), }; - tables.message.insert(row).expect("should insert"); + tables.message.insert(row).await.expect("should insert"); } // Dropped without a drain, standing in for a process that died. } @@ -912,7 +913,11 @@ mod restart_tests { { let tables = Tables::open(&dir).await.expect("should open"); - tables.task_log.insert(row.clone()).expect("should insert"); + tables + .task_log + .insert(row.clone()) + .await + .expect("should insert"); // Without the drain the process can end mid-write, which is how a // page ends up half written rather than merely stale. tables.shutdown().await.expect("tables drain"); diff --git a/apps/gui/src/main.rs b/apps/gui/src/main.rs index f2dc435d9..3dec842b3 100644 --- a/apps/gui/src/main.rs +++ b/apps/gui/src/main.rs @@ -827,6 +827,87 @@ mod restart_resume_tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn a_strict_load_refusal_rebuilds_the_store_and_preserves_the_original() { + use crate::db::schema::project::ProjectRow; + + let root = std::env::temp_dir().join(format!( + "az-strict-store-rebuild-{}-{}", + std::process::id(), + uuid::Uuid::now_v7() + )); + let source = root.join("source"); + let empty = root.join("empty"); + + tauri::async_runtime::block_on(async { + let tables = Tables::open(&source).await.expect("source store opens"); + tables + .project + .insert(ProjectRow { + id: "project-1".into(), + name: "kept".into(), + status: "active".into(), + position: 1, + dirs: "[]".into(), + pinned: false, + moderator_enabled: false, + forked_from: String::new(), + last_activity_at: "2026-09-04T00:00:00Z".into(), + }) + .await + .expect("project inserts"); + tables.shutdown().await.expect("source store drains"); + + let tables = Tables::open(&empty).await.expect("empty store opens"); + tables.shutdown().await.expect("empty store drains"); + }); + std::fs::copy( + empty.join("project/status_idx.wt.idx"), + source.join("project/status_idx.wt.idx"), + ) + .expect("replace the secondary index with a valid but stale one"); + + let error = match tauri::async_runtime::block_on(Tables::open(&source)) { + Ok(tables) => { + tauri::async_runtime::block_on(tables.shutdown()).expect("unexpected store drains"); + panic!("strict load must reject the stale secondary index"); + } + Err(error) => error, + }; + assert!( + is_persistence_load_refusal(&error), + "strict refusal was not classified: {error:?}" + ); + + let mut location = DataLocation { + path: source.clone(), + source: "test".into(), + is_editable: false, + }; + let tables = rebuild_rejected_store(&mut location, &error.to_string()) + .expect("startup recovery rebuild succeeds"); + let rows: Vec = tables + .project + .select_all() + .execute() + .expect("rebuilt rows select"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].id, "project-1"); + assert_eq!(location.path, source); + assert!( + std::fs::read_dir(&root) + .expect("rebuild root exists") + .flatten() + .any(|entry| entry + .file_name() + .to_string_lossy() + .starts_with("source.pre-rebuild-")), + "the rejected source must be retained beside the rebuilt live store" + ); + tauri::async_runtime::block_on(tables.shutdown()).expect("rebuilt store drains"); + let _ = std::fs::remove_dir_all(root); + } + #[test] fn live_project_mutations_never_route_to_fixture_data() { let capabilities = list_capabilities(); @@ -1681,9 +1762,12 @@ pub(crate) async fn apply_settings_patch( let boundary = study::normalize_setting(&previous.study_analytics, &mut parsed.study_analytics); let merged = serde_json::to_value(&parsed).map_err(|error| error.to_string())?; - let boundary_id = boundary - .map(|boundary| study::record_boundary(&state.tables, &parsed.study_analytics, boundary)) - .transpose()?; + let boundary_id = match boundary { + Some(boundary) => { + Some(study::record_boundary(&state.tables, &parsed.study_analytics, boundary).await?) + } + None => None, + }; #[cfg(feature = "blitz-runtime")] let runtime_debug_changed = previous.blitz_control_enabled != parsed.blitz_control_enabled @@ -2189,6 +2273,113 @@ fn migrate_forward(location: &mut location::DataLocation, found: &str) -> Result } } +/// Rebuild a store whose row layout matches but whose persisted indexes fail +/// WorkTable's strict startup audit. +/// +/// Beta 17 made that audit complete. Older builds could leave a secondary +/// index behind its primary index while continuing to serve the table, so an +/// unchanged schema fingerprint is not proof that the persisted index set is +/// internally consistent. The source is opened only in recovery mode by the +/// offline tool, every row is written into fresh indexes, and the destination +/// must strict-open before it is published. +fn is_persistence_load_refusal(error: &eyre::Report) -> bool { + error + .downcast_ref::() + .is_some() +} + +fn rebuild_rejected_store( + location: &mut location::DataLocation, + refusal: &str, +) -> Result { + let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string(); + let next = location + .path + .with_extension(format!("next-rebuild-{stamp}")); + crate::log!( + log::Level::Warn, + "boot", + "WorkTable rejected the store at {:?} ({refusal}); rebuilding every readable row into \ + {next:?} without touching the source", + location.path + ); + + let rebuilt = tauri::async_runtime::block_on(wt_migrate::rebuild_store(&location.path, &next)); + let report = match rebuilt { + Ok(report) => report, + Err(error) => { + let _ = std::fs::remove_dir_all(&next); + crate::log!( + log::Level::Error, + "boot", + "store rebuild failed: {error}. The store at {:?} is byte-for-byte untouched; \ + booting on a scratch store so the app still opens.", + location.path + ); + *location = ephemeral_location(); + return tauri::async_runtime::block_on(Tables::open(&location.path)) + .map_err(|error| format!("could not open a scratch store: {error}")); + } + }; + + let keep = location.path.with_extension(format!("pre-rebuild-{stamp}")); + if let Err(error) = std::fs::rename(&location.path, &keep) { + let _ = std::fs::remove_dir_all(&next); + crate::log!( + log::Level::Error, + "boot", + "could not preserve the rejected store before publishing its rebuild: {error}. The \ + store at {:?} is untouched; booting on a scratch store.", + location.path + ); + *location = ephemeral_location(); + return tauri::async_runtime::block_on(Tables::open(&location.path)) + .map_err(|error| format!("could not open a scratch store: {error}")); + } + + if let Err(error) = std::fs::rename(&next, &location.path) { + match std::fs::rename(&keep, &location.path) { + Ok(()) => { + let _ = std::fs::remove_dir_all(&next); + crate::log!( + log::Level::Error, + "boot", + "could not publish the rebuilt store: {error}. The original is back at {:?}; \ + booting on a scratch store.", + location.path + ); + } + Err(back) => crate::log!( + log::Level::Error, + "boot", + "could not publish the rebuilt store ({error}) or put the original back ({back}). \ + Nothing was deleted: the original is at {keep:?}, the rebuild is at {next:?}, \ + and the expected live path is {:?}. Booting on a scratch store.", + location.path + ), + } + *location = ephemeral_location(); + return tauri::async_runtime::block_on(Tables::open(&location.path)) + .map_err(|error| format!("could not open a scratch store: {error}")); + } + + crate::log!( + log::Level::Info, + "boot", + "rebuilt the rejected WorkTable store: [{}]. The original is kept whole at {keep:?}", + report + .iter() + .map(|(table, rows)| format!("{table}: {rows}")) + .collect::>() + .join(", ") + ); + tauri::async_runtime::block_on(Tables::open(&location.path)).map_err(|error| { + let message = format!("could not open the strictly verified rebuilt store: {error}"); + crate::log!(log::Level::Error, "boot", "{message}"); + message + }) +} + fn main() { // This binary has two diagnostic flags and otherwise launches a GUI. Handle // the conventional read-only CLI exits before Tauri setup opens the store or @@ -2609,19 +2800,24 @@ fn main() { .map_err(|error| format!("could not open a scratch store: {error}"))? } Ok(stored) => match db::tables::check_schema(stored.as_deref()) { - db::tables::SchemaState::Match => tauri::async_runtime::block_on(Tables::open( - &location.path, - )) - .map_err(|error| { - let message = format!( - "could not open the tables in {:?}: {error}. \ + db::tables::SchemaState::Match => { + match tauri::async_runtime::block_on(Tables::open(&location.path)) { + Ok(tables) => tables, + Err(error) if is_persistence_load_refusal(&error) => { + rebuild_rejected_store(&mut location, &error.to_string())? + } + Err(error) => { + let message = format!( + "could not open the tables in {:?}: {error}. \ Relaunch with AZ_NO_PERSIST=1 (or --debug-no-persist) to start \ the app without touching the store, then diagnose.", - location.path - ); - crate::log!(log::Level::Error, "boot", "{message}"); - message - })?, + location.path + ); + crate::log!(log::Level::Error, "boot", "{message}"); + return Err(message.into()); + } + } + } db::tables::SchemaState::Mismatch { found } if no_migration => { crate::log!( log::Level::Warn, diff --git a/apps/gui/src/projects.rs b/apps/gui/src/projects.rs index 505a09750..8368306b6 100644 --- a/apps/gui/src/projects.rs +++ b/apps/gui/src/projects.rs @@ -427,7 +427,7 @@ async fn record_item_completion(tables: &Tables, row: &ProjectItemRow, actor: Op agent, completed_at: now(), }; - if let Err(error) = tables.item_completion.insert(completion) { + if let Err(error) = tables.item_completion.insert(completion).await { crate::log!( crate::log::Level::Warn, "items", @@ -534,7 +534,7 @@ fn usage_json(usage: &agent_abstraction::Usage) -> String { /// Missing fields stay zero in the decomposition and a missing provider cost /// stays zero dollars. The transcript labels a locally estimated cost as such; /// this durable ledger never promotes that estimate into a provider charge. -fn record_turn_usage( +async fn record_turn_usage( tables: &Tables, project_id: &str, agent: Agent, @@ -587,21 +587,21 @@ fn record_turn_usage( at: ledger.at.clone(), }; - if let Err(error) = tables.usage_ledger.insert(ledger) { + if let Err(error) = tables.usage_ledger.insert(ledger).await { crate::log!( crate::log::Level::Error, "run", "{project_id}: could not record the turn usage: {error}" ); } - if let Err(error) = tables.usage_cache.insert(cache) { + if let Err(error) = tables.usage_cache.insert(cache).await { crate::log!( crate::log::Level::Error, "run", "{project_id}: could not record the cache split: {error}" ); } - if let Err(error) = tables.usage_session.insert(session) { + if let Err(error) = tables.usage_session.insert(session).await { crate::log!( crate::log::Level::Error, "run", @@ -848,11 +848,12 @@ async fn record_imported_usage( tables .usage_ledger .insert(ledger) + .await .map_err(|error| error.to_string())?; inserted_ledger = true; } if tables.usage_cache.select(ledger_id.clone()).is_none() { - if let Err(error) = tables.usage_cache.insert(cache) { + if let Err(error) = tables.usage_cache.insert(cache).await { if inserted_ledger { let _ = tables.usage_ledger.delete(ledger_id).await; } @@ -861,7 +862,7 @@ async fn record_imported_usage( inserted_cache = true; } if tables.usage_session.select(ledger_id.clone()).is_none() { - if let Err(error) = tables.usage_session.insert(session) { + if let Err(error) = tables.usage_session.insert(session).await { if inserted_cache { let _ = tables.usage_cache.delete(ledger_id.clone()).await; } @@ -1629,9 +1630,8 @@ fn body_head(body: &str) -> String { /// /// Call after the message row's id is known; the chunks key off it. A body /// within the cap writes nothing. Every caller mints a fresh message id, so -/// there are never prior chunks to clear: this is insert-only and synchronous, -/// which keeps the send path off an await it does not need. -fn store_body(tables: &Tables, message_id: &str, project_id: &str, body: &str) { +/// there are never prior chunks to clear: this is insert-only. +async fn store_body(tables: &Tables, message_id: &str, project_id: &str, body: &str) { if body.len() <= MAX_MESSAGE_BODY { return; } @@ -1645,7 +1645,7 @@ fn store_body(tables: &Tables, message_id: &str, project_id: &str, body: &str) { seq: u32::try_from(seq).unwrap_or(u32::MAX), text: chunk, }; - if let Err(error) = tables.message_chunk.insert(row) { + if let Err(error) = tables.message_chunk.insert(row).await { crate::log!( crate::log::Level::Error, "message", @@ -1716,7 +1716,7 @@ struct AgentMessageOutcome { exit_code: i64, } -fn persist_message_body( +async fn persist_message_body( tables: &Tables, row: MessageRow, body: &str, @@ -1724,8 +1724,9 @@ fn persist_message_body( tables .message .insert(row.clone()) + .await .map_err(|error| error.to_string())?; - store_body(tables, &row.id, &row.project_id, body); + store_body(tables, &row.id, &row.project_id, body).await; let mut dto = MessageDto::from(row); dto.body = body.to_string(); Ok(dto) @@ -1763,7 +1764,7 @@ async fn flush_continued_agent_chunk( body: body_head(&full), created_at: started_at.take().unwrap_or_else(now), }; - match persist_message_body(tables, row, &full) { + match persist_message_body(tables, row, &full).await { Ok(dto) => { let id = dto.id.clone(); let _ = app.emit("message:appended", dto); @@ -1859,7 +1860,7 @@ async fn persist_terminal_agent_chunk( body: body_head(&body), created_at: started_at.unwrap_or_else(now), }; - persist_message_body(tables, row, &body) + persist_message_body(tables, row, &body).await } /// Mirror a child fork's concise result into the parent conversation. @@ -1933,7 +1934,7 @@ async fn persist_fork_handback( body: body_head(&full), created_at: now(), }; - match persist_message_body(tables, row, &full) { + match persist_message_body(tables, row, &full).await { Ok(message) => { let _ = app.emit("message:appended", message); touch_item(tables, &item_id).await; @@ -2249,26 +2250,19 @@ pub async fn create_item( if title.is_empty() { return Err("an item needs a title".into()); } - let tables = std::sync::Arc::clone(&state.tables); - let row = tokio::task::spawn_blocking(move || create_item_row(&tables, project_id, title)) - .await - .map_err(|error| error.to_string())??; + let row = create_item_row(&state.tables, project_id, title).await?; touch_item(&state.tables, &row.id).await; let dto = item_dto(row, &state.tables); let _ = app.emit("item:created", dto.clone()); let mut study = crate::study::Record::manual(dto.project_id.clone(), "items.add", "item", dto.id.clone()); study.latency = Some(started.elapsed()); - crate::study::record(&state.tables, study); + crate::study::record(&state.tables, study).await; Ok(dto) } -/// Validate and persist a new item away from the window and async-runtime threads. -/// -/// WorkTable selection and insertion are synchronous. Running them directly in -/// the async Tauri command held semantic input dispatch and inspector snapshots -/// behind a roughly one-second store write on the release QA profile. -fn create_item_row( +/// Validate and persist a new item without blocking the async runtime. +async fn create_item_row( tables: &Tables, project_id: String, title: String, @@ -2300,6 +2294,7 @@ fn create_item_row( tables .project_item .insert(row.clone()) + .await .map_err(|error| error.to_string())?; Ok(row) } @@ -2368,6 +2363,7 @@ pub async fn fork_item( .tables .project .insert(row.clone()) + .await .map_err(|error| error.to_string())?; if let Err(error) = state .tables @@ -2814,7 +2810,7 @@ pub async fn set_item_status( dto.id.clone(), ); study.latency = Some(started.elapsed()); - crate::study::record(&state.tables, study); + crate::study::record(&state.tables, study).await; Ok(dto) } @@ -2856,7 +2852,7 @@ pub async fn update_item( dto.id.clone(), ); study.latency = Some(started.elapsed()); - crate::study::record(&state.tables, study); + crate::study::record(&state.tables, study).await; Ok(dto) } @@ -2931,7 +2927,7 @@ pub async fn set_item_issue( let dto = link_item_issue_inner(&app, &state.tables, &id, &url).await?; let mut study = crate::study::Record::manual(dto.project_id.clone(), "issue.link", "item", id); study.latency = Some(started.elapsed()); - crate::study::record(&state.tables, study); + crate::study::record(&state.tables, study).await; Ok(dto) } @@ -2956,7 +2952,7 @@ pub async fn delete_item( let _ = app.emit("item:updated", dto); let mut study = crate::study::Record::manual(row.project_id, "items.archive", "item", id); study.latency = Some(started.elapsed()); - crate::study::record(&state.tables, study); + crate::study::record(&state.tables, study).await; return Ok(()); } state @@ -2972,7 +2968,7 @@ pub async fn delete_item( ); let mut study = crate::study::Record::manual(row.project_id, "items.retire", "item", id); study.latency = Some(started.elapsed()); - crate::study::record(&state.tables, study); + crate::study::record(&state.tables, study).await; Ok(()) } @@ -3026,7 +3022,7 @@ pub async fn reorder_items( crate::study::Record::manual(project_id.clone(), "items.reorder", "project", project_id); study.latency = Some(started.elapsed()); study.detail = serde_json::json!({ "itemCount": moved.len() }); - crate::study::record(&state.tables, study); + crate::study::record(&state.tables, study).await; Ok(items) } @@ -3846,7 +3842,7 @@ async fn apply_directive( .unwrap_or(project_id); let pr_number = match pr.as_deref() { Some(url) if url.starts_with("https://github.com/") => { - match crate::prs::record_url(app, tables, target_project, url) { + match crate::prs::record_url(app, tables, target_project, url).await { Ok(number) => Some(number.to_string()), Err(code) => { return Outcome::Refused { @@ -4017,7 +4013,7 @@ async fn apply_directive( forked_from: String::new(), last_activity_at: now(), }; - if let Err(error) = tables.project.insert(row.clone()) { + if let Err(error) = tables.project.insert(row.clone()).await { return Outcome::Refused { what: format!("items.add({title:?}) project"), code: format!("WRITE_FAILED: {error}"), @@ -4123,7 +4119,7 @@ async fn apply_directive( code: format!("WRITE_FAILED: {error}"), }; } - match tables.project_item.insert(row.clone()) { + match tables.project_item.insert(row.clone()).await { Ok(_) => { touch_item(tables, &row.id).await; if let Some(handle) = handle.as_ref() { @@ -4274,7 +4270,7 @@ async fn apply_directive( .map(|(_, project)| project.as_str()) .unwrap_or(project_id); let tracked = match url.as_deref() { - Some(url) => match crate::prs::record_url(app, tables, target_project, url) { + Some(url) => match crate::prs::record_url(app, tables, target_project, url).await { Ok(found) => Some(found.to_string()), Err(code) => { return Outcome::Refused { @@ -4377,7 +4373,9 @@ async fn apply_directive( &text, &urgency, reference.as_deref(), - ) { + ) + .await + { Ok(id) => Outcome::Done(format!("{id} asked ({urgency})")), Err(code) => Outcome::Refused { what: "ask".to_string(), @@ -4638,7 +4636,8 @@ async fn apply_directives_with_state( latency: None, detail: serde_json::json!({}), }, - ); + ) + .await; let started = std::time::Instant::now(); let outcome = apply_directive( app, @@ -4668,7 +4667,8 @@ async fn apply_directives_with_state( latency: Some(started.elapsed()), detail: serde_json::json!({}), }, - ); + ) + .await; done.push(outcome); } Some(crate::directives::Authored::Refused(outcome)) => { @@ -4690,7 +4690,8 @@ async fn apply_directives_with_state( latency: None, detail: serde_json::json!({}), }, - ); + ) + .await; let (result, code) = outcome.study_result(); crate::study::record( tables, @@ -4709,7 +4710,8 @@ async fn apply_directives_with_state( latency: Some(std::time::Duration::ZERO), detail: serde_json::json!({}), }, - ); + ) + .await; done.push(outcome); } None => {} @@ -4744,7 +4746,7 @@ fn user_authored_ps(text: &str) -> bool { }) } -fn record_study_turn( +async fn record_study_turn( tables: &crate::db::tables::Tables, project_id: &str, turn_id: &str, @@ -4783,7 +4785,8 @@ fn record_study_turn( "userAuthoredPs": authored.user_authored_ps, }), }, - ); + ) + .await; } /// Find the project a line named, by id or by name, case-insensitively. @@ -5019,6 +5022,7 @@ async fn write_partial_reply( payload: encoded.to_string(), created_at: now(), }) + .await .map_err(|error| error.to_string())?; // Insert first, then delete: a process death can leave extra snapshots but @@ -5248,8 +5252,9 @@ async fn user_message_for_send( .tables .message .insert(row.clone()) + .await .map_err(|error| error.to_string())?; - store_body(&state.tables, &row.id, &input.project_id, &input.body); + store_body(&state.tables, &row.id, &input.project_id, &input.body).await; // The emitted DTO carries the whole body, not just the stored head: the // caller has it in hand and the reader would otherwise have to round-trip @@ -5264,7 +5269,7 @@ async fn user_message_for_send( message_id: message.id.clone(), created_at: message.created_at.clone(), }; - match state.tables.question_reply.insert(relation) { + match state.tables.question_reply.insert(relation).await { Ok(_) => { message.reply_to_question_id = Some(question.id.clone()); crate::questions::answer_for_reply( @@ -5292,7 +5297,8 @@ async fn user_message_for_send( &input.body, input.study.as_ref(), followup, - ); + ) + .await; note_gui( app, state, @@ -5593,7 +5599,7 @@ async fn recover_partial_reply(tables: &Tables, project_id: &str, raw: String) - body: body_head(&checkpoint_body), created_at: checkpoint.started_at.unwrap_or_else(now), }; - if let Err(error) = tables.message.insert(message) { + if let Err(error) = tables.message.insert(message).await { crate::log!( crate::log::Level::Error, "run", @@ -5601,7 +5607,7 @@ async fn recover_partial_reply(tables: &Tables, project_id: &str, raw: String) - ); return false; } - store_body(tables, &message_id, project_id, &checkpoint_body); + store_body(tables, &message_id, project_id, &checkpoint_body).await; crate::log!( crate::log::Level::Info, "run", @@ -5825,14 +5831,17 @@ fn persist_io(app: &AppHandle, entry: &AgentIoEntry) { kind: entry.kind.clone(), detail: entry.detail.clone(), }; - if let Err(error) = state.tables.agent_io.insert(row) { - crate::log!( - crate::log::Level::Warn, - "io", - "{}: could not persist an I/O line: {error}", - entry.project_id - ); - } + let table = std::sync::Arc::clone(&state.tables.agent_io); + let project_id = entry.project_id.clone(); + tauri::async_runtime::spawn(async move { + if let Err(error) = table.insert(row).await { + crate::log!( + crate::log::Level::Warn, + "io", + "{project_id}: could not persist an I/O line: {error}" + ); + } + }); } /// The raw exchange for one project, oldest first. @@ -5931,7 +5940,7 @@ impl RateLimitReport { /// the turn that just happened and is worthless a day later. pub type Receipts = std::sync::Mutex>>; -fn queue_directive_receipts( +async fn queue_directive_receipts( receipts: &Receipts, tables: &crate::db::tables::Tables, project_id: &str, @@ -5965,7 +5974,8 @@ fn queue_directive_receipts( latency: None, detail: serde_json::json!({ "outcomeCount": outcomes.len() }), }, - ); + ) + .await; } pub type RunningTasks = std::sync::Mutex>>; @@ -7611,6 +7621,7 @@ pub async fn resolve_approval( .tables .approval_rule .insert(row) + .await .map_err(|error| error.to_string())?; crate::log!( crate::log::Level::Info, @@ -8118,7 +8129,7 @@ pub async fn compact_project( // Attribute the handoff pass to the session that actually paid for it // before clearing that id. - record_turn_usage(&state.tables, &project_id, agent, &compact_model, usage); + record_turn_usage(&state.tables, &project_id, agent, &compact_model, usage).await; state .tables .kv_put(&agent_session_key(&project_id, agent), String::new()) @@ -8154,6 +8165,7 @@ pub async fn compact_project( .tables .message .insert(row.clone()) + .await .map_err(|error| error.to_string())?; let _ = app.emit("message:appended", &MessageDto::from(row)); if let Some(project) = state.tables.project.select(project_id.clone()) { @@ -8382,6 +8394,7 @@ pub async fn compact_project( .tables .message .insert(row.clone()) + .await .map_err(|error| error.to_string())?; let _ = app.emit("message:appended", &MessageDto::from(row)); if has_usage { @@ -8390,7 +8403,7 @@ pub async fn compact_project( } else { compact_model.clone() }; - record_turn_usage(&state.tables, &project_id, agent, &model, &compact_usage); + record_turn_usage(&state.tables, &project_id, agent, &model, &compact_usage).await; } let _ = app.emit( "run:compaction", @@ -9682,7 +9695,7 @@ pub async fn import_chat_session( .kv_put(&import_key, project_id.clone()) .await .map_err(|error| error.to_string())?; - if let Err(error) = state.tables.project.insert(row.clone()) { + if let Err(error) = state.tables.project.insert(row.clone()).await { let _ = state.tables.kv.delete(import_key).await; return Err(error.to_string()); } @@ -9721,7 +9734,7 @@ pub async fn import_chat_session( body: body_head(&message.text), created_at: at.to_rfc3339(), }; - match persist_message_body(&state.tables, stored.clone(), &message.text) { + match persist_message_body(&state.tables, stored.clone(), &message.text).await { Ok(dto) => { stored_rows.push(stored); imported.push(dto); @@ -9793,14 +9806,19 @@ pub async fn create_project( forked_from: String::new(), last_activity_at: now(), }; - state.tables.project.insert(row.clone()).map_err(|error| { - crate::log!( - crate::log::Level::Error, - "projects", - "could not insert {project_id}: {error}" - ); - error.to_string() - })?; + state + .tables + .project + .insert(row.clone()) + .await + .map_err(|error| { + crate::log!( + crate::log::Level::Error, + "projects", + "could not insert {project_id}: {error}" + ); + error.to_string() + })?; let project = with_session(ProjectDto::from(row), &state.tables); crate::log!( @@ -10835,7 +10853,7 @@ pub async fn review_pull_request( detail } ); - return append_review_message(&app, &state, &review, body, 1); + return append_review_message(&app, &state, &review, body, 1).await; } Err(error) => { return append_review_message( @@ -10847,7 +10865,8 @@ pub async fn review_pull_request( `gh auth login`, and check the network connection before trying again." ), 1, - ); + ) + .await; } }; @@ -10864,7 +10883,8 @@ pub async fn review_pull_request( &review, format!("the review request could not be configured: {error}"), 1, - ); + ) + .await; } }; @@ -10877,7 +10897,8 @@ pub async fn review_pull_request( &review, format!("the review run failed: {error}"), 1, - ); + ) + .await; } }; @@ -10886,7 +10907,7 @@ pub async fn review_pull_request( } else { (outcome.text, i64::from(outcome.exit_code)) }; - append_review_message(&app, &state, &review, body, exit_code) + append_review_message(&app, &state, &review, body, exit_code).await } struct ReviewMessageContext<'a> { @@ -10898,7 +10919,7 @@ struct ReviewMessageContext<'a> { } /// Persist one visible review outcome, successful or not. -fn append_review_message( +async fn append_review_message( app: &AppHandle, state: &AppState, review: &ReviewMessageContext<'_>, @@ -10931,8 +10952,9 @@ fn append_review_message( .tables .message .insert(row.clone()) + .await .map_err(|error| error.to_string())?; - store_body(&state.tables, &message_id, review.project_id, &body); + store_body(&state.tables, &message_id, review.project_id, &body).await; let mut appended = MessageDto::from(row); appended.body = body; let _ = app.emit("message:appended", &appended); @@ -12345,7 +12367,8 @@ async fn drive_run( &directive_turn_id, agent_wire_name(agent), &done, - ); + ) + .await; } } @@ -12486,7 +12509,7 @@ async fn drive_run( finished_at, }; - if let Err(error) = tables.task_log.insert(TaskLogRow::from(&entry)) { + if let Err(error) = tables.task_log.insert(TaskLogRow::from(&entry)).await { crate::log!( crate::log::Level::Error, "tasks", @@ -13077,7 +13100,7 @@ async fn drive_run( // Record a turn whenever it reported a cost or any token figures. // The same path is used for interrupted turns below, so a stop on // an approval cannot erase work already reported by the provider. - record_turn_usage(&tables, &project_id, agent, &model, &outcome.usage); + record_turn_usage(&tables, &project_id, agent, &model, &outcome.usage).await; /* * One reverse-channel parser for Home and project tabs. The @@ -13132,7 +13155,8 @@ async fn drive_run( &directive_turn_id, agent_wire_name(agent), &done, - ); + ) + .await; } // Apply the child's state directives first. A terminal parent item // makes the separate prose handback redundant, so the helper above @@ -13228,7 +13252,7 @@ async fn drive_run( { Ok(appended) => { let _ = app.emit("message:appended", appended); - record_turn_usage(&tables, &project_id, agent, &model, &turn_usage); + record_turn_usage(&tables, &project_id, agent, &model, &turn_usage).await; clear_partial_reply(&tables, &project_id).await; } // The insert failing is the one case the checkpoint is @@ -13362,7 +13386,7 @@ async fn drive_run( { Ok(appended) => { let _ = app.emit("message:appended", appended); - record_turn_usage(&tables, &project_id, agent, &model, &turn_usage); + record_turn_usage(&tables, &project_id, agent, &model, &turn_usage).await; clear_partial_reply(&tables, &project_id).await; } // Left in place deliberately: the checkpoint is what the @@ -13633,7 +13657,7 @@ async fn checkpoint_if_due( ), created_at: now(), }; - match tables.message.insert(row.clone()) { + match tables.message.insert(row.clone()).await { Ok(_) => { let _ = app.emit("message:appended", &MessageDto::from(row)); } @@ -13714,6 +13738,7 @@ mod tests { forked_from: String::new(), last_activity_at: (*created).into(), }) + .await .expect("project row inserts"); } @@ -14193,6 +14218,7 @@ mod tests { tables .project .insert(project_row("project-a", "Project A")) + .await .expect("project inserts"); let durable_body = format!("{}\n\nThe finished tail.", "verified history ".repeat(40)); @@ -14211,8 +14237,8 @@ mod tests { body: body_head(&durable_body), created_at: "2026-08-07T00:00:00Z".into(), }; - tables.message.insert(row).expect("chunk inserts"); - store_body(&tables, "durable-chunk", "project-a", &durable_body); + tables.message.insert(row).await.expect("chunk inserts"); + store_body(&tables, "durable-chunk", "project-a", &durable_body).await; let legacy = serde_json::to_string(&PartialReply { version: 1, @@ -14259,6 +14285,7 @@ mod tests { tables .project .insert(project_row("project-a", "Project A")) + .await .expect("project inserts"); let durable_body = format!("{} the durable tail", "Tracked reply prefix ".repeat(8)); @@ -14293,6 +14320,7 @@ mod tests { body: body.clone(), created_at: created_at.into(), }) + .await .expect("message inserts"); } @@ -14517,7 +14545,7 @@ mod tests { let tables = Tables::open(&store).await.expect("scope store opens"); let mut row = project_row("proj-cwd", "Cwd"); row.dirs = serde_json::to_string(&vec!["/repo/work"]).unwrap(); - tables.project.insert(row).expect("project inserts"); + tables.project.insert(row).await.expect("project inserts"); // No transcript exists for this id, so the session cannot name a home // directory and the project's own directory stands. The point of the @@ -14556,6 +14584,7 @@ mod tests { tables .project .insert(project_row("proj-reset", "Reset")) + .await .expect("project inserts"); tables .kv_put( @@ -14791,7 +14820,7 @@ mod tests { let tables = Tables::open(&store).await.expect("scope store opens"); let mut row = project_row("proj-roots", "Roots"); row.dirs = serde_json::to_string(&vec!["/repo-a", "/repo-b"]).unwrap(); - tables.project.insert(row).expect("project inserts"); + tables.project.insert(row).await.expect("project inserts"); let scope = invocation_scope( &tables, @@ -14857,7 +14886,7 @@ mod tests { let tables = Tables::open(&store).await.expect("scope store opens"); let mut row = project_row("proj-resume", "Resume roots"); row.dirs = serde_json::to_string(&vec!["/repo-a"]).unwrap(); - tables.project.insert(row).expect("project inserts"); + tables.project.insert(row).await.expect("project inserts"); tables .kv_put( &agent_session_key("proj-resume", Agent::Codex), @@ -14927,7 +14956,7 @@ mod tests { let tables = Tables::open(&store).await.expect("scope store opens"); let mut row = project_row("proj-claude", "Claude roots"); row.dirs = serde_json::to_string(&vec!["/repo-a", "/repo-b"]).unwrap(); - tables.project.insert(row).expect("project inserts"); + tables.project.insert(row).await.expect("project inserts"); let scope = invocation_scope( &tables, @@ -15405,7 +15434,8 @@ mod tests { Agent::Codex, "gpt-5.6-sol", &usage, - ); + ) + .await; let ledger = tables .usage_ledger @@ -15470,6 +15500,7 @@ mod tests { body: "prior answer".into(), created_at: "2026-08-08T00:00:00Z".into(), }) + .await .expect("prior answer inserts"); assert_eq!( @@ -15641,6 +15672,7 @@ mod tests { body: "Imported response".into(), created_at: "2026-08-07T01:02:03Z".into(), }) + .await .expect("imported message inserts"); tables .kv_put( @@ -15704,6 +15736,7 @@ mod tests { tables .project_item .insert(row.clone()) + .await .expect("legacy-shaped item inserts"); assert!( item_dto(row.clone(), &tables).updated_at.is_empty(), @@ -15829,7 +15862,11 @@ mod tests { body: "change course".into(), created_at: now(), }; - tables.message.insert(row).expect("visible row inserts"); + tables + .message + .insert(row) + .await + .expect("visible row inserts"); let input = SendMessageInput { project_id: "proj-steer".into(), body: "change course".into(), @@ -15906,8 +15943,8 @@ mod tests { body: body_head(&body), created_at: now(), }; - tables.message.insert(row).expect("head row inserts"); - store_body(&tables, "msg-big", "proj-big", &body); + tables.message.insert(row).await.expect("head row inserts"); + store_body(&tables, "msg-big", "proj-big", &body).await; // The inline head alone is capped; the whole body comes back only once // the chunks are stitched on. @@ -15967,6 +16004,7 @@ mod tests { created_at: "2026-08-07T00:00:01Z".into(), }; persist_message_body(&tables, before, "Before the reply") + .await .expect("continued chunk persists"); tables .message @@ -15985,6 +16023,7 @@ mod tests { body: "Owner reply".into(), created_at: "2026-08-07T00:00:02Z".into(), }) + .await .expect("owner reply persists"); persist_terminal_agent_chunk( &tables, @@ -16047,6 +16086,7 @@ mod tests { created_at: "2026-08-07T00:00:01Z".into(), }; persist_message_body(&tables, row, "Nothing followed this") + .await .expect("continued chunk persists"); let finalized = persist_terminal_agent_chunk( @@ -16506,6 +16546,7 @@ mod tests { reference: String::new(), priority: 0, }) + .await .expect("item inserts"); let target = study_target_before( @@ -16562,7 +16603,11 @@ mod tests { updated_at: "2026-08-04T00:00:00Z".into(), }, ] { - tables.pull_request.insert(row).expect("PR row inserts"); + tables + .pull_request + .insert(row) + .await + .expect("PR row inserts"); } let snapshot = state_snapshot(&tables, "project-private", Some("item-focused"), true, true); @@ -16625,6 +16670,7 @@ mod tests { reference: String::new(), priority: NORMAL_PRIORITY, }) + .await .expect("item row inserts"); tables .kv_put( @@ -16695,7 +16741,11 @@ mod tests { project_row("project-a", "AgencyZero"), project_row("project-b", "WorkTable"), ] { - tables.project.insert(project).expect("project inserts"); + tables + .project + .insert(project) + .await + .expect("project inserts"); } for item in [ ProjectItemRow { @@ -16717,7 +16767,11 @@ mod tests { priority: 0, }, ] { - tables.project_item.insert(item).expect("item inserts"); + tables + .project_item + .insert(item) + .await + .expect("item inserts"); } tables .kv_put( @@ -16790,7 +16844,7 @@ mod tests { "2026-08-07T02:00:00Z", ), ] { - tables.message.insert(row).expect("message inserts"); + tables.message.insert(row).await.expect("message inserts"); } let delivered = state_snapshot(&tables, "project-review", None, false, true); @@ -16830,6 +16884,7 @@ mod tests { "Continue", "2026-08-07T03:00:00Z", )) + .await .expect("later owner message inserts"); let already_delivered = state_snapshot(&tables, "project-review", None, false, true); assert!(!already_delivered.contains("REVIEW_FINDING_123")); @@ -16865,6 +16920,7 @@ mod tests { reference: String::new(), priority: 0, }) + .await .expect("adaptive item inserts"); tables .kv_put( @@ -16897,6 +16953,7 @@ mod tests { body: "Seen".into(), created_at: "2026-08-07T01:00:00Z".into(), }) + .await .expect("agent message inserts"); tables .message @@ -16915,6 +16972,7 @@ mod tests { body: "Fork handback result".into(), created_at: "2026-08-07T01:30:00Z".into(), }) + .await .expect("handback inserts"); let unchanged = state_snapshot(&tables, "project-adaptive", None, false, true); @@ -16940,6 +16998,7 @@ mod tests { body: "Handback received".into(), created_at: "2026-08-07T01:45:00Z".into(), }) + .await .expect("handback acknowledgement inserts"); let acknowledged = state_snapshot(&tables, "project-adaptive", None, false, true); assert!(!acknowledged.contains("Fork handback result")); @@ -17065,6 +17124,7 @@ mod tests { reference: "119".into(), priority: 0, }) + .await .expect("item inserts"); let written = write_item_status(&tables, "item-delete-now", "finished", true, None) @@ -17138,6 +17198,7 @@ mod tests { reference: String::new(), priority: 0, }) + .await .expect("item inserts"); } @@ -17229,7 +17290,7 @@ mod tests { last_activity_at: now(), }, ] { - tables.project.insert(row).expect("project inserts"); + tables.project.insert(row).await.expect("project inserts"); } tables .project_item @@ -17242,6 +17303,7 @@ mod tests { reference: String::new(), priority: 0, }) + .await .expect("item inserts"); schedule_finished_retirement(&tables, "item-anchor") .await @@ -17302,6 +17364,7 @@ mod tests { reference: String::new(), priority: 0, }) + .await .expect("item inserts"); schedule_finished_retirement(&tables, "item-retire-later") .await @@ -17368,6 +17431,7 @@ mod tests { reference: String::new(), priority: 0, }) + .await .expect("legacy finished item inserts"); assert!( tables @@ -17413,6 +17477,7 @@ mod tests { reference: String::new(), priority: 0, }) + .await .expect("item inserts"); schedule_finished_retirement(&tables, "item-two-halves") .await @@ -17494,6 +17559,7 @@ mod tests { "first request", "2026-08-06T01:00:00Z", )) + .await .expect("first message writes"); tables .message @@ -17504,6 +17570,7 @@ mod tests { "first answer", "2026-08-06T01:01:00Z", )) + .await .expect("answer writes"); tables .message @@ -17514,6 +17581,7 @@ mod tests { "review finding carried across providers", "2026-08-06T01:01:30Z", )) + .await .expect("review writes"); tables .message @@ -17524,6 +17592,7 @@ mod tests { "current request", "2026-08-06T01:02:00Z", )) + .await .expect("current message writes"); let handoff = provider_handoff(&tables, "project-a", "current", Agent::Codex); @@ -17592,6 +17661,7 @@ mod tests { tables .project_item .insert(row) + .await .expect("review item inserts"); let proposed = propose_item_delete(&tables, "item-review-delete") diff --git a/apps/gui/src/prs.rs b/apps/gui/src/prs.rs index 9dca6096b..2f8ac5153 100644 --- a/apps/gui/src/prs.rs +++ b/apps/gui/src/prs.rs @@ -203,7 +203,7 @@ pub fn canonical_rows(rows: Vec) -> Vec { /// Rows are born `unknown` and honest; `gh` upgrades them moments later when /// it is installed and authenticated. The number is returned so the same /// directive can attach it to an item without parsing the URL twice. -pub fn record_url( +pub async fn record_url( app: &AppHandle, tables: &Tables, project_id: &str, @@ -243,14 +243,18 @@ pub fn record_url( dismissed: false, updated_at: crate::projects::now(), }; - tables.pull_request.insert(row.clone()).map_err(|error| { - crate::log!( - crate::log::Level::Error, - "prs", - "{project_id}: could not record {url}: {error}" - ); - format!("WRITE_FAILED: {error}") - })?; + tables + .pull_request + .insert(row.clone()) + .await + .map_err(|error| { + crate::log!( + crate::log::Level::Error, + "prs", + "{project_id}: could not record {url}: {error}" + ); + format!("WRITE_FAILED: {error}") + })?; row } }; @@ -515,7 +519,7 @@ pub fn refresh_project(app: AppHandle, project_id: String) { dismissed: false, updated_at: crate::projects::now(), }; - match state.tables.pull_request.insert(row.clone()) { + match state.tables.pull_request.insert(row.clone()).await { Ok(_) => { let _ = app.emit("pr:updated", PullRequestDto::from(row)); } @@ -711,7 +715,7 @@ pub async fn dismiss_pull_request( let (project_id, _) = dismiss_association(&app, &state.tables, &id).await?; let mut study = crate::study::Record::manual(project_id, "pr.dismiss", "pull_request", id); study.latency = Some(started.elapsed()); - crate::study::record(&state.tables, study); + crate::study::record(&state.tables, study).await; Ok(()) } @@ -730,7 +734,7 @@ pub fn discover_pull_requests(app: AppHandle, project_id: String) { /// Ask `gh` again, for the refresh affordance on the chip. #[tauri::command] -pub fn refresh_pull_request(app: AppHandle, id: String) { +pub async fn refresh_pull_request(app: AppHandle, id: String) { // Asked about one, answered for its whole project: the query costs the // same either way, and a chip nobody clicked is no less stale. let state = app.state::(); @@ -757,7 +761,8 @@ pub fn refresh_pull_request(app: AppHandle, id: String) { latency: None, detail: serde_json::json!({}), }, - ); + ) + .await; refresh_project(app, project); } } diff --git a/apps/gui/src/qa_profile.rs b/apps/gui/src/qa_profile.rs index 85722abab..926b5ec32 100644 --- a/apps/gui/src/qa_profile.rs +++ b/apps/gui/src/qa_profile.rs @@ -518,6 +518,7 @@ async fn copy_scrubbed( tables .project .insert(scrubbed_row) + .await .map_err(|error| error.to_string())?; scrubbed += 1; } @@ -537,6 +538,7 @@ async fn copy_scrubbed( tables .project_item .insert(scrubbed_row) + .await .map_err(|error| error.to_string())?; scrubbed += 1; } @@ -555,6 +557,7 @@ async fn copy_scrubbed( tables .message .insert(scrubbed_row) + .await .map_err(|error| error.to_string())?; scrubbed += 1; } @@ -569,6 +572,7 @@ async fn copy_scrubbed( tables .task_log .insert(scrubbed_row) + .await .map_err(|error| error.to_string())?; scrubbed += 1; } @@ -601,6 +605,7 @@ async fn copy_scrubbed( tables .message_chunk .insert(scrubbed_row) + .await .map_err(|error| error.to_string())?; scrubbed += 1; } @@ -620,6 +625,7 @@ async fn copy_scrubbed( tables .reply_checkpoint .insert(scrubbed_row) + .await .map_err(|error| error.to_string())?; scrubbed += 1; } @@ -637,6 +643,7 @@ async fn copy_scrubbed( tables .agent_io .insert(scrubbed_row) + .await .map_err(|error| error.to_string())?; scrubbed += 1; } @@ -660,6 +667,7 @@ async fn copy_scrubbed( kind: "action".to_owned(), detail: "QA fixture entry".to_owned(), }) + .await .map_err(|error| error.to_string())?; scrubbed += 1; } @@ -674,6 +682,7 @@ async fn copy_scrubbed( tables .question .insert(scrubbed_row) + .await .map_err(|error| error.to_string())?; scrubbed += 1; } @@ -708,6 +717,7 @@ async fn copy_scrubbed( tables .pull_request .insert(row) + .await .map_err(|error| error.to_string())?; scrubbed += 1; } @@ -723,6 +733,7 @@ async fn copy_scrubbed( tables .approval_rule .insert(scrubbed_row) + .await .map_err(|error| error.to_string())?; scrubbed += 1; } @@ -738,6 +749,7 @@ async fn copy_scrubbed( tables .study_event .insert(scrubbed_row) + .await .map_err(|error| error.to_string())?; scrubbed += 1; } @@ -751,6 +763,7 @@ async fn copy_scrubbed( tables .question_reply .insert(row) + .await .map_err(|error| error.to_string())?; scrubbed += 1; } @@ -764,6 +777,7 @@ async fn copy_scrubbed( tables .item_completion .insert(row) + .await .map_err(|error| error.to_string())?; scrubbed += 1; } @@ -777,6 +791,7 @@ async fn copy_scrubbed( tables .usage_ledger .insert(row) + .await .map_err(|error| error.to_string())?; scrubbed += 1; } @@ -790,6 +805,7 @@ async fn copy_scrubbed( tables .usage_cache .insert(row) + .await .map_err(|error| error.to_string())?; scrubbed += 1; } @@ -803,6 +819,7 @@ async fn copy_scrubbed( tables .usage_session .insert(row) + .await .map_err(|error| error.to_string())?; scrubbed += 1; } @@ -818,6 +835,7 @@ async fn copy_scrubbed( tables .kv .insert(scrubbed_row) + .await .map_err(|error| error.to_string())?; scrubbed += 1; } diff --git a/apps/gui/src/questions.rs b/apps/gui/src/questions.rs index 7b7e3f1f2..f1e4a0285 100644 --- a/apps/gui/src/questions.rs +++ b/apps/gui/src/questions.rs @@ -51,7 +51,7 @@ impl From for QuestionDto { /// number: an `https://` value is a GitHub issue, anything else is an item id. /// The row is always emitted so the card lands the moment the directive parses, /// not at the next project load. -pub fn record( +pub async fn record( app: &AppHandle, tables: &Tables, project_id: &str, @@ -76,7 +76,7 @@ pub fn record( answered: false, created_at: crate::projects::now(), }; - tables.question.insert(row.clone()).map_err(|error| { + tables.question.insert(row.clone()).await.map_err(|error| { crate::log!( crate::log::Level::Error, "questions", @@ -227,14 +227,17 @@ mod tests { tables .question .insert(question("open", "project-a", false)) + .await .expect("open question inserts"); tables .question .insert(question("second-open", "project-a", false)) + .await .expect("second question inserts"); tables .question .insert(question("other-project", "project-b", false)) + .await .expect("other project question inserts"); let target = reply_target(&tables, "project-a", Some("open")) @@ -289,6 +292,7 @@ mod tests { tables .question .insert(question("only")) + .await .expect("question inserts"); assert!( reply_target(&tables, "project-a", None) @@ -299,6 +303,7 @@ mod tests { tables .question .insert(question("ambiguous")) + .await .expect("second question inserts"); assert!( reply_target(&tables, "project-a", None) @@ -328,6 +333,7 @@ mod tests { message_id: "message-a".into(), created_at: "2026-08-07T00:00:00Z".into(), }) + .await .expect("reply link inserts"); tables.shutdown().await.expect("tables drain"); diff --git a/apps/gui/src/study.rs b/apps/gui/src/study.rs index 76fbb5606..5a685f8e5 100644 --- a/apps/gui/src/study.rs +++ b/apps/gui/src/study.rs @@ -222,12 +222,12 @@ fn row(setting: &StudyAnalytics, record: Record) -> StudyEventRow { /// Instrumentation never changes the product operation's result. A failed /// study write is logged loudly and the requested task or PR mutation still /// stands, because research collection must not become application authority. -pub fn record(tables: &Tables, record: Record) { +pub async fn record(tables: &Tables, record: Record) { let setting = current_setting(tables); if !setting.enabled || setting.session_id.is_empty() { return; } - if let Err(error) = tables.study_event.insert(row(&setting, record)) { + if let Err(error) = tables.study_event.insert(row(&setting, record)).await { crate::log!( crate::log::Level::Error, "study", @@ -241,7 +241,7 @@ pub fn record(tables: &Tables, record: Record) { /// The caller removes this row if the settings write fails, giving the two /// WorkTable writes transaction-like cleanup without claiming cross-table /// transactions the engine does not provide. -pub fn record_boundary( +pub async fn record_boundary( tables: &Tables, setting: &StudyAnalytics, boundary: Boundary, @@ -269,6 +269,7 @@ pub fn record_boundary( tables .study_event .insert(row) + .await .map_err(|error| error.to_string())?; Ok(id) } @@ -488,7 +489,7 @@ mod tests { #[tokio::test] async fn disabled_collection_writes_nothing() { let tables = tables("off").await; - record(&tables, sample()); + record(&tables, sample()).await; assert!(rows(&tables).is_empty()); } @@ -511,7 +512,7 @@ mod tests { .await .expect("settings persist"); - record(&tables, sample()); + record(&tables, sample()).await; let kept = rows(&tables); assert_eq!(kept.len(), 1); assert_eq!(kept[0].operation, "items.state"); @@ -531,8 +532,12 @@ mod tests { ..enabled.clone() }; - record_boundary(&tables, &enabled, Boundary::Enabled).expect("enable boundary inserts"); - record_boundary(&tables, &disabled, Boundary::Disabled).expect("disable boundary inserts"); + record_boundary(&tables, &enabled, Boundary::Enabled) + .await + .expect("enable boundary inserts"); + record_boundary(&tables, &disabled, Boundary::Disabled) + .await + .expect("disable boundary inserts"); let kept = rows(&tables); assert_eq!(kept.len(), 2); @@ -552,6 +557,7 @@ mod tests { tables .study_event .insert(row(&setting, sample())) + .await .expect("sample inserts"); clear_rows(&tables).await.expect("rows clear"); diff --git a/crates/agency-tools/tests/read_store.rs b/crates/agency-tools/tests/read_store.rs index 506334896..e5b809a79 100644 --- a/crates/agency-tools/tests/read_store.rs +++ b/crates/agency-tools/tests/read_store.rs @@ -60,7 +60,7 @@ async fn write_projects(dir: &Path, rows: Vec) { let engine = ProjectPersistenceEngine::new(config).await.unwrap(); let table = ProjectWorkTable::load(engine).await.unwrap(); for row in rows { - table.insert(row).unwrap(); + table.insert(row).await.unwrap(); } table.wait_for_ops().await.expect("project rows persist"); } @@ -74,7 +74,7 @@ async fn write_items(dir: &Path, rows: Vec) { let engine = ProjectItemPersistenceEngine::new(config).await.unwrap(); let table = ProjectItemWorkTable::load(engine).await.unwrap(); for row in rows { - table.insert(row).unwrap(); + table.insert(row).await.unwrap(); } table .wait_for_ops() @@ -91,7 +91,7 @@ async fn write_descriptions(dir: &Path, rows: Vec) { let engine = KvPersistenceEngine::new(config).await.unwrap(); let table = KvWorkTable::load(engine).await.unwrap(); for row in rows { - table.insert(row).unwrap(); + table.insert(row).await.unwrap(); } table.wait_for_ops().await.expect("descriptions persist"); } @@ -281,7 +281,10 @@ async fn reads_while_a_writer_holds_the_store() { ); let engine = ProjectPersistenceEngine::new(config).await.unwrap(); let writer = ProjectWorkTable::load(engine).await.unwrap(); - writer.insert(project("proj-live", "Live", 1)).unwrap(); + writer + .insert(project("proj-live", "Live", 1)) + .await + .unwrap(); writer.wait_for_ops().await.expect("project rows persist"); // Writer still open, exactly like a running GUI. diff --git a/crates/wt-migrate/src/lib.rs b/crates/wt-migrate/src/lib.rs index ce90034c5..b84a38994 100644 --- a/crates/wt-migrate/src/lib.rs +++ b/crates/wt-migrate/src/lib.rs @@ -113,7 +113,7 @@ mod profile_repair_tests { message("other-project", "other", "2026-08-09T20:03:00+00:00"), message("after", "project", "2026-08-09T20:20:00+00:00"), ] { - source_table.insert(row).unwrap(); + source_table.insert(row).await.unwrap(); } source_table.wait_for_ops().await.unwrap(); source_table.close().await.unwrap(); @@ -689,6 +689,7 @@ pub async fn salvage_items(source: &Path, target: &Path) -> eyre::Result<(usize, } target_table .insert(row) + .await .map_err(|error| eyre::eyre!("{error}"))?; salvaged += 1; } @@ -749,13 +750,21 @@ async fn scrub_items(target: &Path) -> eyre::Result { pub mod app_schema { pub mod agent_io; pub mod approval_rule; + pub mod item_completion; pub mod kv; pub mod message; + pub mod message_chunk; pub mod project; pub mod project_item; pub mod pull_request; + pub mod question; + pub mod question_reply; + pub mod reply_checkpoint; + pub mod study_event; pub mod task_log; + pub mod usage_cache; pub mod usage_ledger; + pub mod usage_session; } /// Merge one project's bounded message window into an existing store. @@ -809,6 +818,7 @@ pub async fn merge_message_window( if target_ids.insert(row.id.clone()) { target_table .insert(row.clone()) + .await .map_err(|error| eyre::eyre!("message {}: {error}", row.id))?; inserted += 1; } @@ -988,47 +998,79 @@ pub async fn clear_fresh_session(target: &Path, project_id: &str, agent: &str) - pub async fn rebuild_store(source: &Path, target: &Path) -> eyre::Result> { macro_rules! carry { ($module:ident, $engine:ident, $table:ident) => {{ - // Progress to stderr before the scan, so the one line a fatal - // signal cuts off names the table that killed it. - eprintln!( - "scanning {}...", - app_schema::$module::$table::name_snake_case() - ); - let open = |dir: &Path| { - let config = worktable::prelude::DiskConfig::new_with_table_name( - dir.to_string_lossy().into_owned(), - app_schema::$module::$table::name_snake_case(), - app_schema::$module::$table::version(), - ); - async move { - let engine = app_schema::$module::$engine::new(config).await?; - app_schema::$module::$table::load(engine).await + let table_name = app_schema::$module::$table::name_snake_case(); + if !source.join(table_name).is_dir() { + None + } else { + // Progress to stderr before the scan, so the one line a fatal + // signal cuts off names the table that killed it. + eprintln!("scanning {table_name}..."); + let open = |dir: &Path, mode| { + let config = worktable::prelude::DiskConfig::new_with_table_name( + dir.to_string_lossy().into_owned(), + table_name, + app_schema::$module::$table::version(), + ); + async move { + let engine = Box::pin(app_schema::$module::$engine::new(config)).await?; + Box::pin(app_schema::$module::$table::load_with(engine, mode)).await + } + }; + let rows = { + // A rebuild is the explicit offline recovery boundary. Beta 17 + // correctly refuses stale cross-index state in normal strict + // opens, but the primary index can still supply individually + // validated rows for a clean destination. + let table = open(source, worktable::prelude::LoadMode::Recovery).await?; + let rows = table.select_all().execute()?; + table.close().await.map_err(|error| { + eyre::eyre!( + "{} source close failed: {error}", + app_schema::$module::$table::name_snake_case() + ) + })?; + rows + }; + let count = rows.len(); + let fresh = open(target, worktable::prelude::LoadMode::Strict).await?; + for row in rows { + fresh.insert(row).await.map_err(|error| { + eyre::eyre!( + "{}: {error}", + app_schema::$module::$table::name_snake_case() + ) + })?; } - }; - let rows = { - let table = open(source).await?; - table.select_all().execute()? - }; - let count = rows.len(); - let fresh = open(target).await?; - for row in rows { - fresh.insert(row).map_err(|error| { + fresh.wait_for_ops().await.map_err(|error| { + eyre::eyre!( + "{} persistence failed: {error}", + app_schema::$module::$table::name_snake_case() + ) + })?; + fresh.close().await.map_err(|error| { + eyre::eyre!( + "{} target close failed: {error}", + app_schema::$module::$table::name_snake_case() + ) + })?; + + // Recovery is complete only when the rebuilt table passes the + // same strict audit used by normal application startup. + let verified = open(target, worktable::prelude::LoadMode::Strict).await?; + let verified_count = verified.select_all().execute()?.len(); + verified.close().await.map_err(|error| { eyre::eyre!( - "{}: {error}", + "{} verification close failed: {error}", app_schema::$module::$table::name_snake_case() ) })?; + eyre::ensure!( + verified_count == count, + "{} strict verification found {verified_count} of {count} rows", + table_name + ); + Some((table_name.to_string(), count)) } - fresh.wait_for_ops().await.map_err(|error| { - eyre::eyre!( - "{} persistence failed: {error}", - app_schema::$module::$table::name_snake_case() - ) - })?; - ( - app_schema::$module::$table::name_snake_case().to_string(), - count, - ) }}; } @@ -1040,7 +1082,17 @@ pub async fn rebuild_store(source: &Path, target: &Path) -> eyre::Result eyre::Result eyre::Result eyre::Result<(usi if row.id.starts_with("log-") && row.project_id.starts_with("proj-") { target_table .insert(row) + .await .map_err(|error| eyre::eyre!("{error}"))?; rebuilt += 1; } else { @@ -1203,6 +1285,7 @@ pub async fn recover_task_log_index( for row in rows.values().cloned() { fresh .insert(row) + .await .map_err(|error| eyre::eyre!("task_log: {error}"))?; } fresh @@ -1260,7 +1343,7 @@ pub async fn recover_message_index( let mut data_file = tokio::fs::File::open(table_path.join(".wt.data")).await?; let mut rows = BTreeMap::new(); for (id, link) in primary_index.iter() { - worktable::data_bucket::seek_by_link(&mut data_file, *link).await?; + worktable::data_bucket::seek_by_link(&mut data_file, link).await?; let mut bytes = vec![0u8; link.length as usize]; data_file.read_exact(&mut bytes).await?; let stored = rkyv::from_bytes::(&bytes) @@ -1294,6 +1377,7 @@ pub async fn recover_message_index( for row in rows.values().cloned() { fresh .insert(row) + .await .map_err(|error| eyre::eyre!("message: {error}"))?; } fresh @@ -1418,6 +1502,7 @@ pub async fn restore_items_from_json(target: &Path, json: &str) -> eyre::Result< }; table .insert(row) + .await .map_err(|error| eyre::eyre!("project_item: {error}"))?; inserted += 1; } @@ -1478,7 +1563,7 @@ pub async fn salvage_item_index(source: &Path, target: &Path) -> eyre::Result eyre::Result ProjectItemRow { ProjectItemRow { id: id.into(), @@ -1803,7 +1961,7 @@ mod recovery_tests { ("item-four", "proj-2"), ("item-five", "proj-3"), ] { - table.insert(item(id, project)).expect("row inserts"); + table.insert(item(id, project)).await.expect("row inserts"); } table.close().await.expect("source closes cleanly"); @@ -1903,9 +2061,18 @@ mod recovery_tests { ); let engine = TaskLogPersistenceEngine::new(config).await.expect("engine"); let table = TaskLogWorkTable::load(engine).await.expect("table"); - table.insert(task("log-1", "proj-1")).expect("first row"); - table.insert(task("log-2", "proj-1")).expect("second row"); - table.insert(task("log-3", "proj-2")).expect("third row"); + table + .insert(task("log-1", "proj-1")) + .await + .expect("first row"); + table + .insert(task("log-2", "proj-1")) + .await + .expect("second row"); + table + .insert(task("log-3", "proj-2")) + .await + .expect("third row"); table.close().await.expect("source closes cleanly"); std::fs::write(source.join("task_log/primary.wt.idx"), b"torn primary") @@ -1956,7 +2123,7 @@ mod recovery_tests { let engine = TaskLogPersistenceEngine::new(config).await.expect("engine"); let table = TaskLogWorkTable::load(engine).await.expect("table"); let id = "log-corrupt".to_string(); - let primary_key = table.insert(task(&id, "proj-1")).expect("row"); + let primary_key = table.insert(task(&id, "proj-1")).await.expect("row"); let link = table .0 .primary_index @@ -2009,11 +2176,18 @@ mod recovery_tests { ); let engine = MessagePersistenceEngine::new(config).await.expect("engine"); let table = MessageWorkTable::load(engine).await.expect("table"); - table.insert(message("msg-1", "proj-1")).expect("first row"); + table + .insert(message("msg-1", "proj-1")) + .await + .expect("first row"); table .insert(message("msg-2", "proj-1")) + .await .expect("second row"); - table.insert(message("msg-3", "proj-2")).expect("third row"); + table + .insert(message("msg-3", "proj-2")) + .await + .expect("third row"); table.close().await.expect("source closes cleanly"); std::fs::write(source.join("message/project_idx.wt.idx"), b"torn secondary") @@ -2075,12 +2249,15 @@ mod recovery_tests { let table = PullRequestWorkTable::load(engine).await.expect("table"); table .insert(pull_request("pr-1", "proj-1")) + .await .expect("first row"); table .insert(pull_request("pr-2", "proj-1")) + .await .expect("second row"); table .insert(pull_request("pr-3", "proj-2")) + .await .expect("third row"); table.close().await.expect("source closes cleanly"); @@ -2149,12 +2326,15 @@ mod recovery_tests { let table = PullRequestWorkTable::load(engine).await.expect("table"); table .insert(pull_request("pr-good-1", "proj-1")) + .await .expect("first row"); table .insert(pull_request("pr-corrupt", "proj-1")) + .await .expect("corrupt row"); table .insert(pull_request("pr-good-2", "proj-2")) + .await .expect("third row"); table.close().await.expect("source closes cleanly"); @@ -2170,7 +2350,7 @@ mod recovery_tests { let primary_index = primary.parse_indexset().await.expect("primary rows"); let corrupt_link = primary_index .iter() - .find_map(|(id, link)| (id == "pr-corrupt").then_some(*link)) + .find_map(|(id, link)| (id == "pr-corrupt").then_some(link)) .expect("corrupt row link"); drop(primary); @@ -2575,19 +2755,25 @@ mod scrub_tests { .expect("engine"); let table = ProjectItemWorkTable::load(engine).await.expect("table"); - table.insert(item("item-1", "proj-846b")).expect("good row"); + table + .insert(item("item-1", "proj-846b")) + .await + .expect("good row"); table .insert(item("item-2", "home-task-manager")) + .await .expect("tm row"); let mut odd = item("item-3", "proj-846b"); odd.status = "someday-maybe".into(); - table.insert(odd).expect("odd status row"); + table.insert(odd).await.expect("odd status row"); // The real debris shapes, verbatim from the incident. table .insert(item("proj-6cf80cb0", "Recover the item list")) + .await .expect("shifted row"); table .insert(item("ment)", "item-03fd09c6")) + .await .expect("worse row"); table.wait_for_ops().await.expect("items persist"); }