Skip to content

Commit e944b85

Browse files
committed
Fix Gmail File Provider send reconciliation
1 parent d3f7099 commit e944b85

6 files changed

Lines changed: 406 additions & 12 deletions

File tree

crates/localityd/src/runtime.rs

Lines changed: 289 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ use locality_core::freshness::{
2828
SyncJobKind,
2929
};
3030
use locality_core::hydration::{HydrationPolicy, HydrationReason, HydrationRequest};
31+
use locality_core::journal::JournalApplyEffect;
3132
use locality_core::model::{
3233
CanonicalDocument, EntityKind, HydrationState, MountId, RemoteId, TreeEntry,
3334
};
@@ -49,7 +50,7 @@ use crate::autosave::{
4950
active_auto_save_enrollment_for_remote_id, auto_save_enrollment_is_active,
5051
auto_save_target_for_write, pause_auto_save_for_remote_change,
5152
};
52-
use crate::execution::{DaemonEventReport, PushJob};
53+
use crate::execution::{DaemonEventReport, PushJob, PushJobReport};
5354
use crate::file_provider::{self, FileProviderReadReport};
5455
use crate::freshness::{
5556
FreshnessQueue, LIVE_MODE_POST_PUSH_SAME_VERSION_PROBE_WINDOW_MS, freshness_timestamp,
@@ -66,7 +67,7 @@ use crate::ipc::{
6667
};
6768
use crate::pull::run_pull_with_state_root;
6869
use crate::push::{
69-
execute_auto_save_push_job_with_content_root, execute_push_job_with_content_root,
70+
PushJobAction, execute_auto_save_push_job_with_content_root, execute_push_job_with_content_root,
7071
};
7172
use crate::reconcile::{
7273
DefaultFetchScheduleStrategy, FetchScheduleStrategy, MountFetchSchedule, ScheduledPullReport,
@@ -1882,17 +1883,21 @@ impl RuntimeState {
18821883
JobCompletion::Pull {
18831884
response,
18841885
respond_to,
1886+
} => {
1887+
let _ = respond_to.send(response);
18851888
}
1886-
| JobCompletion::Push {
1889+
JobCompletion::Push {
18871890
response,
18881891
respond_to,
18891892
} => {
1893+
self.refresh_macos_file_provider_after_gmail_push(&response);
18901894
let _ = respond_to.send(response);
18911895
}
18921896
JobCompletion::AutoPush {
18931897
target_path,
18941898
response,
18951899
} => {
1900+
self.refresh_macos_file_provider_after_gmail_push(&response);
18961901
if response.ok {
18971902
eprintln!(
18981903
"localityd auto-save push completed for `{}`",
@@ -2053,6 +2058,31 @@ impl RuntimeState {
20532058
}
20542059
}
20552060

2061+
fn refresh_macos_file_provider_after_gmail_push(&self, response: &DaemonResponse) {
2062+
let store = match SqliteStateStore::open(self.config.state_root.clone()) {
2063+
Ok(store) => store,
2064+
Err(error) => {
2065+
eprintln!(
2066+
"localityd failed to open state for Gmail File Provider refresh: {error}"
2067+
);
2068+
return;
2069+
}
2070+
};
2071+
if let Err(error) = dispatch_gmail_push_projection_refresh(&store, response, |request| {
2072+
if let Err(error) = refresh_macos_file_provider_container(
2073+
&request.mount_id,
2074+
&request.container_identifier,
2075+
) {
2076+
eprintln!(
2077+
"localityd failed to refresh macOS File Provider for `{}:{}` after Gmail send: {error}",
2078+
request.mount_id, request.container_identifier
2079+
);
2080+
}
2081+
}) {
2082+
eprintln!("localityd failed to plan Gmail File Provider refresh: {error}");
2083+
}
2084+
}
2085+
20562086
fn handle_child_refresh_result(
20572087
&mut self,
20582088
mount_id: &str,
@@ -3358,6 +3388,124 @@ fn child_refresh_priority_label(priority: ChildRefreshPriority) -> &'static str
33583388
}
33593389
}
33603390

3391+
#[derive(Clone, Debug, PartialEq, Eq)]
3392+
struct ProjectionRefreshRequest {
3393+
mount_id: String,
3394+
container_identifier: String,
3395+
}
3396+
3397+
#[derive(Clone, Debug, PartialEq, Eq)]
3398+
struct GmailPushProjectionRefresh {
3399+
mount_id: MountId,
3400+
container_identifiers: Vec<String>,
3401+
}
3402+
3403+
fn gmail_push_projection_refresh(response: &DaemonResponse) -> Option<GmailPushProjectionRefresh> {
3404+
if !response.ok {
3405+
return None;
3406+
}
3407+
let report =
3408+
serde_json::from_value::<PushJobReport>(response.payload.as_ref()?.clone()).ok()?;
3409+
if report.action != PushJobAction::Reconciled {
3410+
return None;
3411+
}
3412+
3413+
let mut container_identifiers = BTreeSet::new();
3414+
if let Some(plan) = report.pipeline.plan.as_ref() {
3415+
for operation in &plan.operations {
3416+
if let PushOperation::CreateEntity { parent_id, .. } = operation {
3417+
container_identifiers.insert(parent_id.0.clone());
3418+
}
3419+
}
3420+
}
3421+
if let Some(execution) = report.execution.as_ref() {
3422+
for effect in &execution.apply_effects {
3423+
if let JournalApplyEffect::CreatedEntity { parent_id, .. } = effect {
3424+
container_identifiers.insert(parent_id.0.clone());
3425+
}
3426+
}
3427+
}
3428+
3429+
(!container_identifiers.is_empty()).then(|| GmailPushProjectionRefresh {
3430+
mount_id: report.mount_id,
3431+
container_identifiers: container_identifiers.into_iter().collect(),
3432+
})
3433+
}
3434+
3435+
fn dispatch_gmail_push_projection_refresh<S, F>(
3436+
store: &S,
3437+
response: &DaemonResponse,
3438+
mut refresh: F,
3439+
) -> locality_core::LocalityResult<()>
3440+
where
3441+
S: MountRepository,
3442+
F: FnMut(ProjectionRefreshRequest),
3443+
{
3444+
let Some(planned) = gmail_push_projection_refresh(response) else {
3445+
return Ok(());
3446+
};
3447+
let Some(mount) = store
3448+
.get_mount(&planned.mount_id)
3449+
.map_err(LocalityError::from)?
3450+
else {
3451+
return Ok(());
3452+
};
3453+
if mount.connector != "gmail" || mount.projection != ProjectionMode::MacosFileProvider {
3454+
return Ok(());
3455+
}
3456+
3457+
for container_identifier in planned.container_identifiers {
3458+
refresh(ProjectionRefreshRequest {
3459+
mount_id: planned.mount_id.0.clone(),
3460+
container_identifier,
3461+
});
3462+
}
3463+
Ok(())
3464+
}
3465+
3466+
fn refresh_macos_file_provider_container(
3467+
mount_id: &str,
3468+
container_identifier: &str,
3469+
) -> Result<(), String> {
3470+
refresh_macos_file_provider_container_impl(mount_id, container_identifier)
3471+
}
3472+
3473+
#[cfg(target_os = "macos")]
3474+
fn refresh_macos_file_provider_container_impl(
3475+
mount_id: &str,
3476+
container_identifier: &str,
3477+
) -> Result<(), String> {
3478+
let Some(helper) = macos_file_provider_helper_path() else {
3479+
return Err("locality-file-providerctl was not found".to_string());
3480+
};
3481+
refresh_macos_file_provider_container_with(
3482+
mount_id,
3483+
container_identifier,
3484+
|action, identifier| run_macos_file_provider_helper_action(&helper, action, identifier),
3485+
)
3486+
}
3487+
3488+
#[cfg(not(target_os = "macos"))]
3489+
fn refresh_macos_file_provider_container_impl(
3490+
_mount_id: &str,
3491+
_container_identifier: &str,
3492+
) -> Result<(), String> {
3493+
Ok(())
3494+
}
3495+
3496+
fn refresh_macos_file_provider_container_with<F>(
3497+
mount_id: &str,
3498+
container_identifier: &str,
3499+
mut run: F,
3500+
) -> Result<(), String>
3501+
where
3502+
F: FnMut(&str, &str) -> Result<(), String>,
3503+
{
3504+
let identifier =
3505+
file_provider::macos_file_provider_item_identifier(mount_id, container_identifier);
3506+
run("signal", &identifier)
3507+
}
3508+
33613509
fn signal_macos_file_provider_enumerator(
33623510
mount_id: &str,
33633511
container_identifier: &str,
@@ -5534,10 +5682,16 @@ mod tests {
55345682
ChangeHintKind, FreshnessTier, RemoteObservation, RemoteVersion, SyncJob, SyncJobKind,
55355683
};
55365684
use locality_core::hydration::{HydrationReason, HydrationRequest};
5685+
use locality_core::journal::{JournalApplyEffect, JournalStatus, PushId, PushOperationId};
55375686
use locality_core::model::{
55385687
CanonicalDocument, EntityKind, HydrationState, MountId, RemoteId, SourceSpan,
55395688
};
5689+
use locality_core::planner::{GuardrailDecision, PushOperation, PushPlan};
5690+
use locality_core::push::{
5691+
PushExecutionAction, PushExecutionResult, PushPipelineAction, PushPipelineResult,
5692+
};
55405693
use locality_core::shadow::{MarkdownBlockKind, ShadowBlock, ShadowDocument};
5694+
use locality_core::validation::ValidationReport;
55415695
use locality_store::{
55425696
AutoSaveEnrollmentRecord, AutoSaveOrigin, AutoSaveRepository, AutoSaveState, ConnectionId,
55435697
ConnectionRecord, ConnectionRepository, EntityRecord, EntityRepository,
@@ -5547,19 +5701,148 @@ mod tests {
55475701
RemoteObservationRecord, RemoteObservationRepository, ShadowRepository, SqliteStateStore,
55485702
};
55495703

5704+
use crate::execution::PushJobReport;
55505705
use crate::ipc::DaemonResponse;
5706+
use crate::push::PushJobAction;
55515707
use crate::virtual_fs::VirtualFsRefreshChildrenReport;
55525708
use crate::watcher::{FileEvent, FileEventKind};
55535709

55545710
use super::{
55555711
ActiveChildRefresh, ActiveRuntimeJob, ChildRefreshPriority, ChildRefreshQueue,
55565712
ChildRefreshRequest, DaemonRequest, DefaultRuntimeJobRunner, JobCompletion,
5557-
RemoteDiscoveryHint, RuntimeJobRunner, RuntimeState, child_refresh_retry_delay,
5558-
execute_file_event, execute_observe_entity_job, locality_error_code,
5559-
observable_remote_identifier, remote_fast_forward_discovery_hints,
5713+
ProjectionRefreshRequest, RemoteDiscoveryHint, RuntimeJobRunner, RuntimeState,
5714+
child_refresh_retry_delay, dispatch_gmail_push_projection_refresh, execute_file_event,
5715+
execute_observe_entity_job, locality_error_code, observable_remote_identifier,
5716+
refresh_macos_file_provider_container_with, remote_fast_forward_discovery_hints,
55605717
repair_clean_remote_deleted_projections, response_file_opened_observe_jobs,
55615718
};
55625719

5720+
fn reconciled_gmail_send_response() -> DaemonResponse {
5721+
let draft_folder_id = RemoteId::new("gmail-folder:draft");
5722+
let sent_folder_id = RemoteId::new("gmail-folder:sent");
5723+
let sent_message_id = RemoteId::new("gmail-message:sent-1");
5724+
let operation_id = PushOperationId("create-gmail-draft".to_string());
5725+
let push_id = PushId("push-gmail-draft".to_string());
5726+
let apply_effect = JournalApplyEffect::CreatedEntity {
5727+
operation_id,
5728+
operation_index: 0,
5729+
parent_id: sent_folder_id,
5730+
entity_id: sent_message_id.clone(),
5731+
};
5732+
let pipeline = PushPipelineResult {
5733+
validation: ValidationReport::clean(),
5734+
plan: Some(PushPlan::new(
5735+
vec![draft_folder_id.clone()],
5736+
vec![PushOperation::CreateEntity {
5737+
parent_id: draft_folder_id,
5738+
parent_kind: Some(EntityKind::Directory),
5739+
parent_workspace: false,
5740+
title: "Test message".to_string(),
5741+
properties: BTreeMap::new(),
5742+
body: "Body".to_string(),
5743+
source_path: "draft/test-message.md".into(),
5744+
}],
5745+
)),
5746+
guardrail: GuardrailDecision::Proceed,
5747+
action: PushPipelineAction::ProceedToApply,
5748+
completed_stages: Vec::new(),
5749+
};
5750+
let execution = PushExecutionResult {
5751+
push_id: push_id.clone(),
5752+
action: PushExecutionAction::Reconciled,
5753+
changed_remote_ids: vec![sent_message_id.clone()],
5754+
apply_effects: vec![apply_effect],
5755+
reconciled_remote_ids: vec![sent_message_id],
5756+
journal_status: Some(JournalStatus::Reconciled),
5757+
completed_stages: Vec::new(),
5758+
};
5759+
DaemonResponse::ok(PushJobReport {
5760+
target_path: "/tmp/gmail-main/draft/test-message.md".into(),
5761+
mount_id: MountId::new("gmail-main"),
5762+
entity_id: RemoteId::new("local:gmail-draft"),
5763+
pipeline,
5764+
readable_diff: None,
5765+
action: PushJobAction::Reconciled,
5766+
execution: Some(execution),
5767+
push_id: Some(push_id),
5768+
journal_status: Some(JournalStatus::Reconciled),
5769+
error: None,
5770+
})
5771+
}
5772+
5773+
#[test]
5774+
fn reconciled_gmail_send_refreshes_draft_and_sent_file_provider_containers() {
5775+
let mut store = InMemoryStateStore::new();
5776+
store
5777+
.save_mount(
5778+
MountConfig::new(MountId::new("gmail-main"), "gmail", "/tmp/gmail-main")
5779+
.projection(ProjectionMode::MacosFileProvider),
5780+
)
5781+
.expect("save Gmail mount");
5782+
let mut refreshes = Vec::new();
5783+
5784+
dispatch_gmail_push_projection_refresh(
5785+
&store,
5786+
&reconciled_gmail_send_response(),
5787+
|request| refreshes.push(request),
5788+
)
5789+
.expect("dispatch projection refresh");
5790+
5791+
assert_eq!(
5792+
refreshes,
5793+
vec![
5794+
ProjectionRefreshRequest {
5795+
mount_id: "gmail-main".to_string(),
5796+
container_identifier: "gmail-folder:draft".to_string(),
5797+
},
5798+
ProjectionRefreshRequest {
5799+
mount_id: "gmail-main".to_string(),
5800+
container_identifier: "gmail-folder:sent".to_string(),
5801+
},
5802+
]
5803+
);
5804+
}
5805+
5806+
#[test]
5807+
fn gmail_send_refresh_skips_non_file_provider_mounts() {
5808+
let mut store = InMemoryStateStore::new();
5809+
store
5810+
.save_mount(
5811+
MountConfig::new(MountId::new("gmail-main"), "gmail", "/tmp/gmail-main")
5812+
.projection(ProjectionMode::LinuxFuse),
5813+
)
5814+
.expect("save Gmail mount");
5815+
let mut refreshes = Vec::new();
5816+
5817+
dispatch_gmail_push_projection_refresh(
5818+
&store,
5819+
&reconciled_gmail_send_response(),
5820+
|request| refreshes.push(request),
5821+
)
5822+
.expect("dispatch projection refresh");
5823+
5824+
assert!(refreshes.is_empty());
5825+
}
5826+
5827+
#[test]
5828+
fn gmail_file_provider_refresh_signals_reconciled_containers() {
5829+
let mut actions = Vec::new();
5830+
5831+
refresh_macos_file_provider_container_with(
5832+
"gmail-main",
5833+
"gmail-folder:draft",
5834+
|action, identifier| {
5835+
actions.push((action.to_string(), identifier.to_string()));
5836+
Ok(())
5837+
},
5838+
)
5839+
.expect("signal container");
5840+
5841+
assert_eq!(actions.len(), 1);
5842+
assert_eq!(actions[0].0, "signal");
5843+
assert_ne!(actions[0].1, "gmail-folder:draft");
5844+
}
5845+
55635846
#[test]
55645847
fn update_required_has_stable_runtime_error_code() {
55655848
assert_eq!(

docs/daemon.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,13 @@ from the legacy app-group content tree into the current content root before
313313
reading or writing the new root; existing current-root files are never
314314
overwritten.
315315

316+
After a Gmail send reconciles, the daemon signals both structural parents
317+
recorded by the push: the source `draft/` folder and the destination `sent/`
318+
folder. File Provider sync anchors encode the prior item identifiers, so change
319+
enumeration reports both the deleted draft identifier and the newly updated Sent
320+
listing. Anchors from older installations expire cleanly and force a complete
321+
enumeration instead of retaining a stale draft placeholder.
322+
316323
Scheduled reconciliation skips writing placeholder Markdown files for virtual
317324
filesystem projection modes such as `macos_file_provider` and `linux_fuse`; it
318325
updates durable entity state, queues policy hydration, and caches database

docs/e2e-behavior-coverage.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ Coverage labels:
160160
| E2E-039 | Granola connects with an API key, paginates and incrementally lists real notes, retrieves and renders a stable summary/transcript pair, mounts meetings through the real CLI/daemon/Linux FUSE path, remains clean on repeat discovery, and rejects filesystem writes. | Covered live | `crates/locality-granola/tests/live_integrity.rs::live_public_api_paginates_fetches_transcript_and_renders_canonical_files`; `tests/live_granola_vfs_read.sh`; Granola connector, renderer, discovery-checkpoint, and virtual read-only tests. | The live suite is read-only against an existing generic meeting, runs with isolated local state, verifies no credential appears in reports or SQLite, and emits privacy-safe failure diagnostics. Real macOS File Provider remains a signed-app manual smoke because hosted runners cannot exercise a logged-in Finder session reliably. |
161161
| E2E-040 | Creating an untracked Notion database `_schema.yaml` under an existing page plans, creates, reads back, and reconciles a real database whose generated schema can immediately validate and create rows. | Covered live | `crates/loc-cli/tests/e2e_push_workflow.rs::live_locality_database_draft_creates_reconciles_and_accepts_rows`; database draft parser/apply, daemon reconciliation, CLI create, and virtual projection tests. | The live test creates a scratch parent, creates the database only through Locality's draft/diff/push path, verifies database and data-source properties through the API, verifies canonical assigned IDs locally, creates a row through the reconciled schema, and archives all scratch content. |
162162
| E2E-041 | `loc mv` stages local moves/renames without pushing, rejects unsafe targets before mutation, and routes virtual projection moves through the same rename path as File Provider/FUSE. | Local only | `crates/loc-cli/tests/mv.rs`; `crates/loc-cli/tests/projection_contract.rs`; `crates/locality-store/tests/virtual_move.rs`; `localityd` `rename_virtual_fs_item` tests. | Local CLI coverage verifies parser/JSON output, usage and validation errors, read-only rejection, destination-directory semantics, cross-mount rejection, no-overwrite behavior, plain-files status/diff visibility, virtual page-directory moves, pending-created page moves, collision rollback, and pending `pending_virtual_rename` status. Existing virtual projection/store tests cover shared provider rename semantics and atomic move persistence. |
163+
| E2E-042 | Sending a Gmail draft removes it from the macOS File Provider `draft/` directory and makes the canonical sent message appear under `sent/` without an explicit pull or Finder refresh. | Local; macOS live requires a healthy provider database | `crates/localityd/tests/push_execution.rs::daemon_push_reconciles_sent_gmail_draft_create_to_sent_folder`; `localityd::runtime::tests::reconciled_gmail_send_refreshes_draft_and_sent_file_provider_containers`; `localityd::runtime::tests::gmail_file_provider_refresh_signals_reconciled_containers`; `LocalityFileProviderItemTests.testSyncAnchorIdentifiesDeletedItems`; `LocalityFileProviderItemTests.testLegacyTimestampSyncAnchorExpires`. | Daemon reconciliation coverage proves the draft cache and mutation are removed while the sent entity and bytes are materialized. Runtime and Swift coverage proves reconciled Gmail sends signal both containers, current anchors emit deleted identifiers, and legacy anchors force a complete enumeration. Installed-app verification must first confirm with `fileproviderctl check` that `draft/` and `sent/` are provider-owned rather than throttled disk-only directories; existing provider-database drift blocks a valid OS-level assertion and requires an explicit repair before this row can be promoted to macOS live coverage. |
163164

164165
## Live Granola Test Coverage Map
165166

0 commit comments

Comments
 (0)