Skip to content

Commit 071da9e

Browse files
committed
fix: seed cached file provider descendants
1 parent ea5fca6 commit 071da9e

11 files changed

Lines changed: 394 additions & 44 deletions

File tree

crates/localityd/src/file_provider.rs

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use locality_store::{
1717
ProjectionMode, ShadowRepository, StoreError, VirtualMutationRepository,
1818
};
1919
use serde::{Deserialize, Serialize};
20+
use std::collections::{BTreeSet, VecDeque};
2021
use std::path::{Path, PathBuf};
2122
use std::sync::atomic::{AtomicU64, Ordering};
2223
use std::time::{SystemTime, UNIX_EPOCH};
@@ -109,6 +110,56 @@ where
109110
})
110111
}
111112

113+
pub fn file_provider_domain_working_set<S>(
114+
store: &S,
115+
state_root: &Path,
116+
domain_id: &str,
117+
) -> LocalityResult<FileProviderDomainChildrenReport>
118+
where
119+
S: MountRepository + EntityRepository + VirtualMutationRepository,
120+
{
121+
let domain = file_provider_domain_children(store, domain_id)?;
122+
let mut children = Vec::new();
123+
124+
for mount_root in domain.children {
125+
let mount_id = MountId::new(mount_root.mount_id.clone());
126+
let content_root = virtual_fs::virtual_fs_content_root(state_root, &mount_id);
127+
let mut pending_containers = VecDeque::from([mount_root.item.identifier.clone()]);
128+
let mut visited_containers = BTreeSet::new();
129+
let mut visited_items = BTreeSet::new();
130+
children.push(mount_root);
131+
132+
while let Some(container_identifier) = pending_containers.pop_front() {
133+
if !visited_containers.insert(container_identifier.clone()) {
134+
continue;
135+
}
136+
let report = virtual_fs::virtual_fs_children_with_content_root(
137+
store,
138+
&content_root,
139+
&mount_id,
140+
&container_identifier,
141+
)?;
142+
for item in report.children {
143+
if !visited_items.insert(item.identifier.clone()) {
144+
continue;
145+
}
146+
if item.kind == FileProviderItemKind::Folder {
147+
pending_containers.push_back(item.identifier.clone());
148+
}
149+
children.push(FileProviderDomainChild {
150+
mount_id: mount_id.0.clone(),
151+
item,
152+
});
153+
}
154+
}
155+
}
156+
157+
Ok(FileProviderDomainChildrenReport {
158+
domain_id: domain_id.to_string(),
159+
children,
160+
})
161+
}
162+
112163
pub fn file_provider_item<S>(
113164
store: &S,
114165
mount_id: &MountId,
@@ -4050,6 +4101,139 @@ mod tests {
40504101
assert_eq!(report.children[1].item.identifier, "mount:notion-main");
40514102
}
40524103

4104+
#[test]
4105+
fn shared_macos_file_provider_working_set_recursively_lists_cached_items() {
4106+
let state_root = temp_root("locality-file-provider-working-set");
4107+
let mount_id = MountId::new("notion-main");
4108+
let mut store = InMemoryStateStore::new();
4109+
store
4110+
.save_mount(
4111+
MountConfig::new(
4112+
mount_id.clone(),
4113+
"notion",
4114+
state_root.join("visible/notion"),
4115+
)
4116+
.projection(ProjectionMode::MacosFileProvider),
4117+
)
4118+
.expect("save notion mount");
4119+
for (remote_id, title, path) in [
4120+
("company", "Company", "Company/page.md"),
4121+
("compliance", "Compliance", "Company/Compliance/page.md"),
4122+
(
4123+
"privacy",
4124+
"Privacy Policy",
4125+
"Company/Compliance/Privacy Policy/page.md",
4126+
),
4127+
] {
4128+
store
4129+
.save_entity(EntityRecord::new(
4130+
mount_id.clone(),
4131+
RemoteId::new(remote_id),
4132+
EntityKind::Page,
4133+
title,
4134+
path,
4135+
))
4136+
.expect("save nested Notion page");
4137+
}
4138+
4139+
let report =
4140+
file_provider_domain_working_set(&store, &state_root, MACOS_FILE_PROVIDER_DOMAIN_ID)
4141+
.expect("working set");
4142+
let items = report
4143+
.children
4144+
.iter()
4145+
.map(|child| {
4146+
(
4147+
child.mount_id.as_str(),
4148+
child.item.identifier.as_str(),
4149+
child.item.parent_identifier.as_deref(),
4150+
child.item.filename.as_str(),
4151+
child.item.path.as_str(),
4152+
&child.item.kind,
4153+
)
4154+
})
4155+
.collect::<Vec<_>>();
4156+
4157+
assert_eq!(
4158+
items,
4159+
vec![
4160+
(
4161+
"notion-main",
4162+
"mount:notion-main",
4163+
Some("root"),
4164+
"notion",
4165+
"notion",
4166+
&FileProviderItemKind::Folder,
4167+
),
4168+
(
4169+
"notion-main",
4170+
"guidance:AGENTS.md",
4171+
Some("mount:notion-main"),
4172+
"AGENTS.md",
4173+
"AGENTS.md",
4174+
&FileProviderItemKind::File,
4175+
),
4176+
(
4177+
"notion-main",
4178+
"guidance:CLAUDE.md",
4179+
Some("mount:notion-main"),
4180+
"CLAUDE.md",
4181+
"CLAUDE.md",
4182+
&FileProviderItemKind::File,
4183+
),
4184+
(
4185+
"notion-main",
4186+
"children:company",
4187+
Some("mount:notion-main"),
4188+
"Company",
4189+
"Company",
4190+
&FileProviderItemKind::Folder,
4191+
),
4192+
(
4193+
"notion-main",
4194+
"children:compliance",
4195+
Some("children:company"),
4196+
"Compliance",
4197+
"Company/Compliance",
4198+
&FileProviderItemKind::Folder,
4199+
),
4200+
(
4201+
"notion-main",
4202+
"company",
4203+
Some("children:company"),
4204+
"page.md",
4205+
"Company/page.md",
4206+
&FileProviderItemKind::File,
4207+
),
4208+
(
4209+
"notion-main",
4210+
"compliance",
4211+
Some("children:compliance"),
4212+
"page.md",
4213+
"Company/Compliance/page.md",
4214+
&FileProviderItemKind::File,
4215+
),
4216+
(
4217+
"notion-main",
4218+
"children:privacy",
4219+
Some("children:compliance"),
4220+
"Privacy Policy",
4221+
"Company/Compliance/Privacy Policy",
4222+
&FileProviderItemKind::Folder,
4223+
),
4224+
(
4225+
"notion-main",
4226+
"privacy",
4227+
Some("children:privacy"),
4228+
"page.md",
4229+
"Company/Compliance/Privacy Policy/page.md",
4230+
&FileProviderItemKind::File,
4231+
),
4232+
]
4233+
);
4234+
let _ = fs::remove_dir_all(state_root);
4235+
}
4236+
40534237
#[test]
40544238
fn shared_macos_file_provider_domain_children_distinguish_same_connector_mount_points() {
40554239
let mut store = InMemoryStateStore::new();

crates/localityd/src/ipc.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ pub enum DaemonRequest {
103103
FileProviderDomainChildren {
104104
domain_id: String,
105105
},
106+
FileProviderDomainWorkingSet {
107+
domain_id: String,
108+
},
106109
}
107110

108111
impl DaemonRequest {
@@ -132,6 +135,7 @@ impl DaemonRequest {
132135
Self::FileProviderMaterialize { .. } => "file_provider_materialize",
133136
Self::FileProviderRead { .. } => "file_provider_read",
134137
Self::FileProviderDomainChildren { .. } => "file_provider_domain_children",
138+
Self::FileProviderDomainWorkingSet { .. } => "file_provider_domain_working_set",
135139
}
136140
}
137141
}
@@ -785,6 +789,22 @@ mod tests {
785789
);
786790
}
787791

792+
#[test]
793+
fn file_provider_domain_working_set_command_decodes() {
794+
let request: DaemonRequest = serde_json::from_str(
795+
r#"{"command":"file_provider_domain_working_set","domain_id":"loc"}"#,
796+
)
797+
.expect("decode File Provider working-set request");
798+
799+
assert_eq!(
800+
request,
801+
DaemonRequest::FileProviderDomainWorkingSet {
802+
domain_id: "loc".to_string(),
803+
}
804+
);
805+
assert_eq!(request.command_name(), "file_provider_domain_working_set");
806+
}
807+
788808
#[test]
789809
fn named_pipe_endpoint_reports_not_implemented() {
790810
let endpoint = DaemonEndpoint::WindowsNamedPipe(r"\\.\pipe\loc-test".to_string());

crates/localityd/src/runtime.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,17 @@ pub trait RuntimeJobRunner: Send + Sync + 'static {
470470
"runtime runner does not handle File Provider domain enumeration",
471471
)
472472
}
473+
474+
fn run_file_provider_domain_working_set(
475+
&self,
476+
_state_root: PathBuf,
477+
_domain_id: String,
478+
) -> DaemonResponse {
479+
DaemonResponse::error(
480+
"unsupported",
481+
"runtime runner does not handle File Provider working-set enumeration",
482+
)
483+
}
473484
}
474485

475486
#[derive(Clone, Debug, Default, PartialEq, Eq)]
@@ -902,6 +913,21 @@ impl RuntimeJobRunner for DefaultRuntimeJobRunner {
902913
}
903914
}
904915

916+
fn run_file_provider_domain_working_set(
917+
&self,
918+
state_root: PathBuf,
919+
domain_id: String,
920+
) -> DaemonResponse {
921+
let store = match SqliteStateStore::open(state_root.clone()) {
922+
Ok(store) => store,
923+
Err(error) => return DaemonResponse::error("store_open_failed", error.to_string()),
924+
};
925+
match file_provider::file_provider_domain_working_set(&store, &state_root, &domain_id) {
926+
Ok(report) => DaemonResponse::ok(report),
927+
Err(error) => DaemonResponse::error(locality_error_code(&error), error.to_string()),
928+
}
929+
}
930+
905931
fn run_virtual_fs_commit_write(
906932
&self,
907933
state_root: PathBuf,
@@ -1773,6 +1799,13 @@ impl RuntimeState {
17731799
.run_file_provider_domain_children(self.config.state_root.clone(), domain_id);
17741800
let _ = respond_to.send(response);
17751801
}
1802+
DaemonRequest::FileProviderDomainWorkingSet { domain_id } => {
1803+
let response = self.runner.run_file_provider_domain_working_set(
1804+
self.config.state_root.clone(),
1805+
domain_id,
1806+
);
1807+
let _ = respond_to.send(response);
1808+
}
17761809
DaemonRequest::VirtualFsCommitWrite {
17771810
mount_id,
17781811
identifier,

crates/localityd/src/server.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,8 @@ fn trace_daemon_request_attrs(span: &mut crate::trace::TraceSpan, request: &Daem
376376
span.attr("projection_root", projection_root.display().to_string());
377377
span.attr("projection", projection.as_str());
378378
}
379-
DaemonRequest::FileProviderDomainChildren { domain_id } => {
379+
DaemonRequest::FileProviderDomainChildren { domain_id }
380+
| DaemonRequest::FileProviderDomainWorkingSet { domain_id } => {
380381
span.attr("domain_id", domain_id.as_str());
381382
}
382383
DaemonRequest::Ping

docs/desktop-app.md

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -212,14 +212,17 @@ state; missing helpers or extensions remain explicit setup failures.
212212
Later source mounts reuse the existing shared File Provider domain. Adding a
213213
top-level source folder such as `google-calendar-main` signals the working-set
214214
enumerator because macOS can drop root-container signals when no root enumerator
215-
is active. Compact working-set sync anchors reference rebuildable item-version
216-
snapshots in the File Provider app-group cache, so change enumeration reports
217-
only new, changed, or deleted items while the anchor stays below macOS's
218-
500-byte limit. A missing or incompatible cached snapshot expires the anchor
219-
and lets macOS perform a clean enumeration. Adding Calendar therefore inserts
220-
the Calendar mount and its children without re-reporting or reimporting an
221-
unchanged `notion` subtree. The shared root is never reimported or
222-
re-registered. Registration is idempotent when the domain already exists;
215+
is active. The working set reads the complete known projection recursively from
216+
durable local daemon state; it must not make connector API requests while
217+
enumerating folders. This seeds already-discovered nested Notion directories in
218+
macOS before the user opens them. Compact sync anchors reference rebuildable
219+
item-version snapshots in the File Provider app-group cache, so later change
220+
enumeration reports only new, changed, or deleted items while the anchor stays
221+
below macOS's 500-byte limit. A missing or incompatible cached snapshot expires
222+
the anchor and lets macOS perform a clean enumeration. Adding Calendar therefore
223+
inserts the Calendar mount and its cached descendants without re-reporting or
224+
reimporting an unchanged `notion` subtree. The shared root is never reimported
225+
or re-registered. Registration is idempotent when the domain already exists;
223226
automatic setup treats that registration as authoritative rather than removing
224227
and recreating it to repair metadata. Reimport and readiness repair stay scoped
225228
to the new mount-point identifier.
@@ -234,8 +237,9 @@ CloudStorage root, and the mount root are all verified successfully.
234237
Do not show hydration queues, polling intervals, or low-level daemon concepts in
235238
the onboarding UI. Do not make the user wait for the full workspace projection
236239
or initial sync to finish before moving forward. Once Notion is connected, Locality
237-
should begin prefetching top-level directories and files so the chosen mount
238-
point feels populated quickly. The UI should not show an extra checklist screen
240+
should begin background discovery and recursively publish all already-cached
241+
directory and file metadata to File Provider so navigating those folders does
242+
not wait on a live Notion request. The UI should not show an extra checklist screen
239243
where most items complete instantly; once the folder and agent instructions are
240244
ready, route directly to the final ready screen and show background sync as a
241245
short supporting detail rather than a task the user waits on.

docs/enumeration-and-hydration.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,15 @@ Implementation:
377377
macOS `enumerateItems` calls into the daemon children path. The daemon client
378378
maps provider children requests to `DaemonRequest::FileProviderChildren`.
379379

380+
The special working-set enumerator uses
381+
`DaemonRequest::FileProviderDomainWorkingSet`. That request recursively flattens
382+
the complete projection already present in durable daemon state, including
383+
nested page directories and their `page.md` files, without running connector
384+
child enumeration. This lets macOS seed nested placeholders in one ingestion
385+
pass even when earlier per-container signals arrived before parent placeholders
386+
existed. Item-version snapshots make later working-set signals delta-aware, so
387+
unchanged sibling mounts are not re-reported.
388+
380389
Fetching file contents uses a different path: `fetchContents` calls the daemon
381390
read/materialize path, which can hydrate. Folder enumeration itself is child
382391
metadata listing.

platform/macos/LocalityFileProvider/README.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,16 @@ Finder must enter the File Provider domain so directory listings call
7171

7272
Mount activation signals the working-set enumerator after adding a source
7373
because macOS can ignore a root-container signal when no root enumerator is
74-
active. Compact working-set sync anchors reference rebuildable item-version
75-
snapshots in the File Provider app-group cache; subsequent change enumerations
76-
report only new, changed, or deleted items while anchors stay within macOS's
77-
500-byte limit. A missing or incompatible snapshot expires its anchor and falls
78-
back to a clean enumeration. Adding a source can therefore insert its mount
79-
point and immediate children without updating an unchanged sibling subtree.
80-
Reimport and readiness repair stay scoped to the new mount-point identifier.
74+
active. The working set recursively reads every already-known item from local
75+
daemon state, without calling connector APIs, so macOS can ingest cached nested
76+
directories before Finder opens them. Compact sync anchors reference
77+
rebuildable item-version snapshots in the File Provider app-group cache;
78+
subsequent change enumerations report only new, changed, or deleted items while
79+
anchors stay within macOS's 500-byte limit. A missing or incompatible snapshot
80+
expires its anchor and falls back to a clean enumeration. Adding a source can
81+
therefore insert its mount point and cached descendants without updating an
82+
unchanged sibling subtree. Reimport and readiness repair stay scoped to the new
83+
mount-point identifier.
8184
Because macOS creates a source folder asynchronously, Locality waits for it
8285
before inspecting it and retries the scoped refresh once. Automatic activation
8386
never resets or re-registers the shared domain. Reconnecting an existing source

0 commit comments

Comments
 (0)