1340 Commits
Author SHA1 Message Date
Jamie Pineandcapy-ai-staging[bot] fc1798b05a Windows unit job gates main; daemon raises its fd limit; sd-version marker test (#3153)
* CI: the Windows unit job gates main

Core Tests (windows unit) was green twice on main (15d7df1, e4b4cb7), 40 min
cold and 21 min warm, so continue-on-error goes and the name loses its
caveat. cache-on-failure stays so a red run still warms the fix's run.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Daemon raises its own file descriptor limit at startup

The daemon shelled out to 'ulimit -n' to read the limit and warned when it
was under 10000, which it is at every launchd start on a Mac with a plist
written before #3111 (8192). Now it calls setrlimit: the hard limit when
finite, else 65536 then OPEN_MAX, since macOS reports unlimited but refuses
a soft limit above kern.maxfilesperproc. One info line records the result;
the warning stays only when the raise fails. The desktop app's LaunchAgent
template gains the same SoftResourceLimits NumberOfFiles 65536 the CLI's
already has.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* sd update: version marker writer with a unit test

The updater already wrote sd-version.txt after an install (#3111). The write
moves into write_version_marker so a test can prove the format: the full sha
on one line with a trailing newline, as the rebuild script writes it.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Raise the fd limit before Core::new and warn when the raised limit is still low

Library pools, watchers and the network endpoint open descriptors inside
Core::new, so the raise has to come first. A raise that lands under 10000
(a container with hard 8192) now still warns.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
2026-10-09 13:22:00 +00:00
Jamie Pineandcapy-ai-staging[bot] e4b4cb71f5 Capture time and place in listings and search, read from the image facet on both backends (#3151)
* Capture time and place in listings and search, read from the image facet on both backends

search.files sorts by CapturedAt and filters by a CapturedAt range and
has_location; files.directory_listing sorts by datetaken; the media
listing's datetaken reads the facet. The store joins facet_image into
every entry read, guarded by the row's content hash, and the arena path
reads the facet from the store ahead of its sort and filter, so both
backends hand the same File to one predicate and one order (R8 row 19,
new row 29). File.image_media_data is filled from the facet on every
path; the inspector shows the capture time and the coordinates; Date
Taken is in every explorer view's sort menu.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* search.media judges the capture filters on its rows and leaves replicas out under them; a directory listing reads facets ahead of the sort only for a capture order

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* search.media judges a replica's rows under a capture filter as search.files does instead of dropping the partition

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
2026-10-09 10:30:21 +00:00
Jamie Pineandcapy-ai-staging[bot] 15d7df1808 Windows: build and unit-test the daemon and CLI on a Windows runner, fix what it found, publish Windows nightlies (#3148)
* ci: build and unit-test sd-core and sd-cli on a Windows runner

The job is the first Windows build since the rewrite and the evidence for
the cross-platform release gate. It runs on GitHub's hosted windows-latest
(Blacksmith has no Windows), builds sd-cli and sd-daemon in debug, then runs
the sd-core lib tests and the sd-cli tests. continue-on-error keeps it from
gating main until it has been green twice.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* cli: name the daemon binary and replace binaries the way Windows allows

The CLI built the daemon's path as sd-daemon everywhere, which does not
exist on Windows; sd_client::daemon_binary_name appends the platform's
executable suffix. sd update renamed the staged file over the running
binary, which Windows refuses for a loaded executable: it now moves the
current binary to .bak first and renames the new one into place, removing
the backup where the platform lets it and on the next update otherwise.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* indexing: keep the hidden bit in the arena instead of re-reading it on every read

The arena rebuilt is_hidden from the path on every entry read. On Unix that
is a name check; on Windows it is a GetFileAttributesW call per entry, and it
answers false for anything not on disk at that moment, so search returned
hidden files from a snapshot or a detached volume. PackedMetadata now carries
a hidden bit (size drops to 59 bits), the arena records what the walk saw,
and the writer judges a change by the metadata it carries. Snapshot restore
recomputes the bit from names where names decide.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* volume: tell a Windows mount point from the directory a drive left behind

is_mount_point answered by existence on Windows, so the empty folder an
unplugged mapped drive leaves passed as mounted and the detached volume's
source could be dispatched at it. GetVolumePathNameW names the mount point
of the volume holding a path; a directory that is its own volume root is a
mount point, anything else resolves to a parent volume.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* core: let three path-shaped unit tests read the same on Windows

The compare and media tests compared displayed paths against slash-written
expectations, and the track action test offered a Unix absolute path that
Windows reads as relative. The tests now normalise separators and pick an
absolute root the platform accepts; what they assert is unchanged.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* files: delete a moved source when the filesystem still has it, not when the volume index guesses

After a streaming copy in move mode the source was deleted only when the
volume manager said the two paths were on different volumes. On Windows it
answered same-volume for a copy the router had already decided to stream, so
the file stayed in both places and the move reported success. The job now
checks whether the source still exists after the strategy ran and removes it
then, which is the condition the comment described.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* ci: save the Windows rust cache on a red run too

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* ci: publish Windows x86_64 nightly binaries

The nightly matrix gains x86_64-pc-windows-msvc on windows-latest, producing
sd-windows-x86_64.exe and sd-daemon-windows-x86_64.exe with checksums. The
build and prepare steps run under bash on every host, and the asset names
carry .exe, which the updater already strips when matching.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* docs: Windows gap report and register row

docs/plans/2026-10-09-windows-gap.md records the first Windows build since
the rewrite: what compiled, what passed, each failure with its cause and
fix, what the integration, FDA, R8 and locked volumes suites need to run on
Windows, and the beta.1 recommendation with release notes text.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* review: judge mount points on the path as given, mark placeholder directories hidden, keep the rename-over update on Unix

GetVolumePathNameW judges the string it is handed, so a subst or mapped
drive letter must not be canonicalized to its target first or its own root
reads as not a mount point. Arena::ensure_directory creates directory
placeholders the walk has not statted; they now carry the hidden answer the
path gives until the walk corrects them. The updater moves the running
binary aside only on Windows, which needs it; elsewhere a .bak copy and a
rename over the target keep a working file at the path at every instant.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
2026-10-09 08:39:47 +00:00
Jamie Pineandcapy-ai-staging[bot] 6dc6c2bef8 Housekeeping: drop sources triggers, remove libraries.export stub, prune restore-trash (#3143)
* ci: drop the sources branch from nightly and core tests triggers

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* core: remove the libraries.export stub left behind by backup/restore

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* core: prune restore-trash after a successful library restore

A restore --replace parks the displaced library and store files under
restore-trash/<library>-<time>/ and never removed them. Once the restored
library is open, keep the newest backup.restore_trash_keep entries for
that library (default 2, settable with sd config set) and remove the rest,
never the one the restore just parked. Removed paths are logged and
returned in the restore output.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* tests: add the backup field to the sync harness AppConfig literal

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
2026-10-08 22:36:51 +00:00
Jamie Pineandcapy-ai-staging[bot] 731744277e Add to Library step 4: keep an offline copy of an on-source catalog (#3142)
* Add to Library step 4: keep an offline copy of an on-source catalog as a library replica

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Say where a detached source's catalog is: offline copy, or on the drive

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Retire only a reader on the offline copy when publishing; drop the unused apply_setting

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Fill the new field in the CLI's source row test

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Fill offline_copy in sources.get; show the catalog hint on the card whether or not the drive has a badge

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
2026-10-08 20:41:46 +00:00
Jamie Pineandcapy-ai-staging[bot] a19553f6f1 Locked volumes L5: volume state in the app, CLI and peer volume facts (#3141)
* Locked volumes L5: volume state in the app, CLI and peer volume facts

RemoteVolumeInfo carries VolumeState and a device publishes its tracked
unmounted and locked volumes, so a peer lists the owner's dataset as
locked instead of a replica that stopped updating. RemoteSourceInfo
carries each source's volume_state and sources.get reads the live index.

VolumeMountChanged carries the state and fires on any state change,
including a key unloaded from an already unmounted dataset. The CLI
event printer names a lock; sd sources list has a State column and
sd volumes list a State line.

The sidebar, Storage cards, source detail and Overview show Locked,
Unmounted or Offline and say the index stays browsable. The Add to
Library modal refuses a locked or unmounted drive with the reason.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Refuse an add only for this device's away drives; printer falls back to is_mounted without a state

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Say why RemoteVolumeInfo.state stays last

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Hold the Add to Library refusal until the device list resolves

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
2026-10-08 19:03:02 +00:00
Jamie Pineandcapy-ai-staging[bot] 26b67ff0d1 Extension file kinds K3 to K5: client renderer registry, viewer bundles, photos viewer (#3136)
* Extension file kinds K3 to K5: client renderer registry, viewer bundles, photos viewer

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Review: allow viewer bundle imports under the desktop CSP, clear the bundle host on unmount

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
2026-10-08 07:33:27 +00:00
Jamie Pineandcapy-ai-staging[bot] 0e76551463 Nightly release: edit in place instead of delete and recreate (#3137)
* Nightly release: edit in place instead of delete and recreate

Every gh release create notified everyone watching the repository for
releases, about ten times a day. The publish job now moves the nightly
tag with git, edits the release title and notes, uploads assets with
--clobber and removes assets whose platform disappeared, creating the
release only when it is missing. sd update reads the commit from the
git ref API (annotated tags dereferenced) with the nightly.sha asset as
the rate-limit fallback, because target_commitish is frozen at creation.
A release without nightly.sha is recreated one last time so updaters
built before the switch still see a newer build.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Nightly publish: upload assets before moving the tag; treat only 'release not found' as missing

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Nightly publish: upload nightly.sha after the binaries it names

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Nightly publish: key the one-time recreate on the release notes, written before any upload

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Nightly publish: one marker variable for the notes and the recreate probe

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
2026-10-08 06:40:45 +00:00
Jamie Pineandcapy-ai-staging[bot] cbdc1db3fa sd mcp: MCP server over the operation registry (#3131)
* sd mcp: MCP server over the operation registry

Every registered query, action, validate and preview is an MCP tool
served over stdio with rmcp, with input schemas walked from the ops'
specta types and a jobs.wait tool to follow jobs.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* docs: note that dotted MCP tool names are deliberate

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* sd mcp: repair a stale library selection; run the suite in the acceptance job

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* sd mcp: jobs.wait finds the job in the library that owns it

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* sd mcp: resolve jobs.wait candidates once and pin the owning library

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
2026-10-08 00:15:48 +00:00
Jamie Pineandcapy-ai-staging[bot] 30b4f19ba4 Scan the installed @spacedrive/primitives for Tailwind classes when spaceui is not beside the repo (#3127)
* Scan the installed @spacedrive/primitives for Tailwind classes when spaceui is not checked out beside the repo

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Also scan the repo-root node_modules for @spacedrive/primitives, for bun's hoisted linker

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
2026-10-08 00:05:40 +00:00
Jamie Pineandcapy-ai-staging[bot] f69c1fee89 Add to Library core: defaults and overrides, store placement, volume registration, portable identity, removal keeps the catalog (#3121)
* Add to Library defaults: library settings with per-add overrides

Every entry point used to carry its own notion of what a source starts as:
volumes.track defaulted external drives to unfiltered, folder tracking to
filtered, and the Explorer sent unfiltered: false by hand. The library now
holds the defaults under settings.adding (store placement in library, an
offline copy kept, filtered capture, content identified), config.library.get
reports them and config.library.update changes them for later adds only.

sources.track and volumes.track take the same AddOverrides on top; an absent
field is the default and an override never writes back. The effective
settings are the source's SourceConfig, persisted with the registration and
returned in the output together with the saved name and the store path.
A name, settings or volume row that fails to persist now fails the add
instead of reporting a tracked source. A source added without content
identification gets its walk and nothing after. volumes.track keeps the
archival behavior by passing an explicit unfiltered override for an external
drive unless the caller set one.

The CLI gains --on-source, --in-library, --keep-offline-copy and
--no-identify on sd source track. Generated TypeScript types regenerated.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Resolve store placement per library in the source layer

Placement is intent recorded in each source's config column; the serving
device resolves it. VolumeIndex::store_dir is the one resolver: in library
under <data>/sources/<id>, on source under <root>/.spacedrive/sources/<id>,
following the source's current root so an on-source store tracks its volume
across remounts and resolves to nothing while the drive is away. Writer
opens, read-only opens, freezes, the status surface and sources.list all go
through it, and SourceStore::open takes the resolved stores directory rather
than the data-directory layout. On source is refused for network and cloud
volumes, whose WAL database cannot live on the share.

The volume index also gains register_source_with, which registers under an
identity a store on disk already carries, and forget_source, which drops a
registration and retires its writer and reader handles without touching the
directory.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Adding a path tracks its volume

ensure_volume_in_db wrote the row but skipped the sync publication and the
VolumeTracked event that track_volume emits, and the add logged its failure
and carried on. It now syncs and emits on insert, the add fails when the row
cannot be written, and the volume index maps the drive at the same moment so
the mount point check and the detached flag apply to the new source. A second
folder on the same drive finds the row already there.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Portable source identity and managed-directory exclusions

Every store now carries source.json beside data.db: a versioned descriptor
with the source id, the library that wrote it, its volume anchor and path
within the volume, and its settings. Adding a scope first looks for a store
whose descriptor binds to this library, this volume and this path, and
registers under that identity, so a removed folder re-added or a drive
plugged in with its catalog on it reopens the catalog instead of minting a
fresh source over an empty one. A descriptor from another library or another
drive is ignored and a new source starts beside it.

The own-data-directory check becomes crate::config::is_managed: the data
directory under every spelling, any .spacedrive directory and what is under
it, and the volume identity marker. It is a path-component check rather than
a registry lookup, so it holds for an unfiltered source whose rules are off,
for an enclosing source that was never told about the inner store, for a
store on a drive nobody here tracks, and across remounts and aliases. The
walk, the watcher and the writer all use it.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Removing a source keeps its catalog unless asked to delete it

sources.delete used to delete the store directory through the adapter engine
before unregistering, which would have turned removal of an on-source store
into destruction of the portable catalog, assertions included. It now
retires the source's handles, drops the registration and leaves the store
where it is; delete_catalog is an explicit second choice. The volume row is
never touched: removing the last folder on a drive does not forget the
hardware. sd source untrack wraps the action.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Add to Library acceptance suite and status

One Core over temporary directories tracks a folder with defaults and one
with overrides, proves one volume row for both, finds the on-source store
excluded from its own and an enclosing source, removes a source and sees
its catalog and record identities survive the re-add, and sees deletion
start over. The suite joins the acceptance group of xtask test-core;
docs/core/acceptance/add-to-library.md maps the plan's cases to tests and
names the rows the core half cannot prove. PROJECT_STATUS and the plan's
status line record what landed and what the modal thread builds next.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Review fixes: placement-aware reads and backup, re-track keeps settings, adoption across placements

The library's per-source read queries (sources.list_items, list_records,
media_listing) opened the store through the engine's in-library layout, so
an on-source catalog was unreachable from the explorer; they now resolve a
registered filesystem source through the volume index and fall through to
the engine only for adapter sources. libraries.backup reads each store from
where placement put it. A re-track carries the previous value of every
setting the add did not name instead of the library's current defaults,
and never changes placement. portable_identity searches both placements so
a drive arriving with its catalog is adopted whatever placement the add
asked for, and the adopted catalog keeps the placement it was found under.
SourceConfig::default agrees with its serde defaults.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Restore names an on-source catalog it cannot put back

The archive now holds a store backed up from its drive, but a restore lays
every store in the in-library layout, where an on-source registration does
not read. The manifest records each source's placement; a restore leaves an
on-source catalog where it landed, keeps it out of the restored count, and
names it in on_source_catalogs with its path, so the caller knows what was
not put back. Moving it onto the drive is relocation.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* catalog_reused means an identity was kept, not that a file exists

The acceptance run on CI saw a fresh add report its catalog reused: the
content identity job of an enclosing source opened the new source's store
between registration and the file probe. The flag now answers from the
registration (it existed, or a descriptor-bound catalog was adopted), which
is what it claims.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Regenerate TypeScript types for the catalog_reused doc

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Volume tracking test follows the one-library-per-path rule

volumes.track now surfaces a source setup failure instead of logging it,
so tracking a volume another open library holds is refused. The
multi-library test asserts the refusal, then the handover once the first
library untracks the drive and removes its source.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
2026-10-07 20:25:10 +00:00
Jamie Pineandcapy-ai-staging[bot] 92dea58a46 ci: enforce the docs build, generated types, secrets scan, and license gates (#3118)
* docs: escape the <name> placeholder in library-sync-setup so MDX compiles

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* tauri: regenerate capability schemas for the updater plugin and pinned tauri-build

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* ci: enforce the docs build, generated types, secrets scan, and license gates on every PR

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* docs: name the ci.yml job behind each repository release gate

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* tauri: keep desktop-schema.json in its committed key order

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* ci: compare Tauri schemas as JSON since key order follows feature unification

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
2026-10-07 06:55:45 +00:00
Jamie Pineandcapy-ai-staging[bot] 08eb622e29 Library backup and restore with manifest verification (#3115)
* Library backup and restore with manifest verification

libraries.backup copies library.db, sync.db, every owned source store and
its sidecars with VACUUM INTO while the daemon runs, hashes each file with
blake3 and writes manifest.json to a directory or .tar.zst archive.
libraries.backup.verify rehashes a backup without restoring it.
libraries.restore verifies every hash, refuses a backup from a newer
schema, stages under the data dir, swaps files into place with the open
store and sidecar pools released first, parks the displaced state in
restore-trash/, and reopens the library. Replacing a library with other
member devices needs force, since their device rows and sync watermarks
would diverge.

CLI: sd library backup --to, sd library backup verify --from, sd library
restore --from [--as-new | --replace] [--force].

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* CLI help: restore-trash is kept, not pruned

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Restore: keep stores closed across the swap, clear the arena, serialize library.json

Review findings on #3115: the volume index kept the pre-restore arena and
drive snapshots after a replace, a watcher event could reopen the old
data.db between the release and the rename, and library.json was copied
byte for byte while the daemon rewrites it in place.

quiesce_stores takes every affected source's open gate and holds it until
the renames are done, closes the cached handles, detaches the library's
registrations and partitions, and removes the drive snapshots so the
reopened library rebuilds from the restored stores. Backup serializes the
open library's config instead of copying the file, and restore parses the
staged config before touching the data directory. A failure after the
first displacement now names the restore-trash directory. The test feeds
source b's watcher during the replace and proves a later write reaches
the restored file on disk.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Restore: load the staged config as LibraryConfig, hold sidecars across the swap

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
2026-10-07 05:21:35 +00:00
Jamie Pineandcapy-ai-staging[bot] e58507a1ec R8 fixes F1, F2, F3, F5: salvage repair, snapshot quarantine, refused watch state, nested-store search duplicates (#3114)
* Store: unsettle a salvaged-away row so the next observation rewrites it

R8 row 3 (F1). When a batch loses a row to a database failure the ledger
kept the binding it made before the commit, so the next walk resolved the
same file Unchanged and the row stayed missing for as long as its size and
mtime held. commit now takes the ledger and unsettles each row it could not
land: the uuid keeps its path, the size and mtime evidence is cleared, and
the next observation resolves Changed and writes the whole row under the
identity the arena already holds. Un-ignores
a_repeat_observation_repairs_a_failed_write.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Snapshot: move an unreadable artifact aside instead of deleting it

R8 row 10 (F2). load_snapshot_impl removed a snapshot it could not read,
decode, or accept the version of, so the artifact was gone before anyone
could inspect it. It now moves to <name>.corrupt-<unix seconds> beside the
slot with a WARN naming both paths; the slot is clear for the next save and
no launch parses it again. Un-ignores an_invalid_snapshot_is_retained_for_diagnosis,
which now asserts the bytes survive at the aside path.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Watcher: register a root only after the OS accepts, report and retry refusals

R8 row 11 (F3). watch_root registered the root with the volume index before
subscribing at the OS, so a refused subscription left the root in
watched_paths and is_watched and core.index_status reported a watch that did
not exist. The OS subscription now comes first; a refusal is recorded on the
volume index with its reason, surfaces as IndexStatus::refused_watches (and
in sd index status), and is retried every watch_retry_interval (30 s) while
the service runs. A retry the OS accepts moves the root to watched_paths.
Un-ignores a_refused_watch_is_not_reported_active and extends it with the
status report and the reconciling retry. Regenerates ts-client types.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Search: answer a file under nested sources once from the stores

R8 row 25 (F5). With an outer and an inner source both walked, the file
under the inner root sits in both stores, and after a restart with no
snapshot search_every_index queried each store in turn and returned it
twice. The store loop now reads sources innermost first and drops a hit
whose local path an earlier partition (an inner store or a drive's arena)
already answered, before it is counted, so total_found stays the number of
distinct files. Un-ignores a_file_under_nested_sources_is_one_hit_from_the_stores.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Docs: R8 rows 3, 10, 11 and 25 fixed; row 17 remains the R4 brief

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Review: one retained corrupt snapshot, full-arena dedupe seed, reset clears refusals

A recurring load failure no longer retains an artifact per launch: when a
.corrupt-* sibling exists the slot is removed instead, keeping the oldest
copy as the mismatched-root path does. The store de-duplication seed is
collected from each arena's full match set before the page window narrows
it, so total_found stays exact beyond the page. clear_all forgets refused
watches along with the watched paths.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
2026-10-07 05:02:25 +00:00
Jamie Pineandcapy-ai-staging[bot] 5bc474e722 CLI and daemon housekeeping from the live Mac runs (#3111)
* Default the replication block in core.status so a newer CLI reads an older daemon

A 10dbc18 CLI asking a pre-#3109 daemon for status over --device failed
with 'missing field replication'. ReplicationStatus now derives Default
and the field carries serde(default); a unit test deserializes the
pre-#3109 payload shape. ts-client types regenerated.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Expose replication.paused through sd config get, set and show

The pause switch was only readable through sd status; config get reported
an unknown key. get reads it from the daemon config, set calls the
existing mounts.replication_set_paused action, show lists it, and the
help text names every key get and set accept.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Answer sync traffic for an absent library once per connection, with an Error reply

A member that deleted its copy of a library stays in the other members'
device table, so every catch-up they ran logged an ERROR on the receiver
and timed out on the sender. The multiplexer now logs one WARN per
connection (keyed on the peer's connected_at) and debug after that,
answers requests with the existing SyncMessage::Error variant, and the
backfill request helpers turn that reply into a failure with the peer's
reason so the catch-up loop backs off instead of marking the peer caught
up. No wire change.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Read paired and connected counts in core.status from the network registry

core.status hardcoded both to zero, so sd status said Paired 0 while
sd network status said Paired 1. Both now read the device registry,
addresses come from the endpoint, and the rows are labelled
'Paired devices' and 'Connected devices'.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Resumable, logged sd update with sd update status

Downloads land in <data>/updates/<asset>.<build>.part and resume with a
Range request on the next attempt; a transfer that moves no bytes for
60 s is abandoned for that run with the part kept; the sha256 is checked
on the complete part before either binary is replaced. Every attempt
appends a line with its result and reason to logs/update.log and writes
update-state.json; sd update status prints the running build, the
sd-version.txt marker, the last attempt, any part waiting to resume and
the timer state. After an install the build commit (or release tag) is
written to sd-version.txt beside the binaries, the same marker the local
rebuild script writes. The launchd agent already had RunAtLoad; the docs
now say so.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Ask launchd for 65536 file descriptors in the daemon agent

The plist set 8192 and the core warned about it at every boot.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* List jobs without loading each job's persisted state blob

jobs.list read full job rows, including the serialized job state; an
indexer over millions of files keeps its seen-path set there, so a
hundred rows took 45 s over a remote link and not much less locally.
The listing and the running-job fallback now select only the columns
the row needs through a partial model. The payload is unchanged.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Drop maximize-build-space from the Blacksmith release and server jobs

The action leaves a Blacksmith runner with about 100 MB of root disk.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Register the housekeeping row in PROJECT_STATUS.md

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Make main the branch of record: nightly and core tests trigger on main

main was fast-forwarded to sources at 10dbc18. nightly.yml and
core_tests.yml now trigger on main, with sources kept in the lists for
one release so a stray push there still builds; the nightly docs and
the PROJECT_STATUS.md register row name main.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Bound every updater HTTP read with the idle timeout

The checksum and release fetches awaited their bodies with no timeout,
so a stalled proxy could hang a timer run before anything was logged.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
2026-10-05 19:54:56 +00:00
Jamie Pineandcapy-ai-staging[bot] 10dbc18d0f Replica fetch: resumable .part transfers, bandwidth cap, pause, and progress (#3109)
* Resume interrupted replica fetches from a .part file

A replica transfer now lands in <source>.<generation>.<blake3 prefix>.part
and stays there when the stream breaks or the daemon stops. The next
attempt asks the owner for the artifact's tail from the part's length over
the new FetchSnapshotFrom and FetchDatabaseFrom requests, whose header
still names the whole artifact; a header that names different bytes
discards the part and starts over, and the finished file is validated
against the header checksum before it replaces the live replica as before.
Parts are removed only when the owner's listing no longer carries their
generation. A nonzero offset serves the on-disk snapshot without the
pre-fetch save, so a resumed copy finishes against the same bytes.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Cap replica transfer bandwidth with a persisted daemon setting

replication.max_bytes_per_sec in spacedrive.json bounds every replica
artifact served and received through one token bucket (zero is
unlimited); the cap applies to transfers already in flight. It is set
through config.app.update and sd config set replication.max_bytes_per_sec
(bytes, or K/M/G suffixes), read back through sd config get and show.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Pause and resume replica fetching

sd sources replication pause stops this device starting replica
transfers and halts any in flight at its next chunk, keeping the partial
file; resume continues from it and runs a replication pass against every
connected device at once. The switch is replication.paused in the daemon
config, so a paused daemon stays paused across a restart, and a pass that
runs while paused still refreshes listings, owner facts and fleet totals.
A pause does not count as a fetch failure, so no backoff accrues.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Show replica fetch progress in sources list, status and events

Every fetch in flight is tracked with bytes on disk, total and a recent
rate. sources.list carries it on the replica's row (or a replica_fetching
row for a first copy that has no share yet), core.status and the new
mounts.replication_status query summarize it with the pause switch and
cap, and the daemon emits a typed ReplicationProgress event about once a
second per transfer plus a finished one when it stops. sd sources list and
sd status print the figures; ts-client types regenerated.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Document resumable replica fetch, the cap, pause and progress

Source replication gets its own section in networking.mdx with the .part
contract, the wire change, the cap, the pause switch and the progress
fields; file-sync.mdx points there; the replication plan marks its cleanup
item superseded on the replica side and distinct from the restart refetch;
PROJECT_STATUS.md gains the register row.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Keep the part when a resumed request gets no header; cap sidecar pages

A resumed request that fails outright (dropped link, or an owner too old
to know the offset variants) says nothing about the bytes on disk, so the
part now stays and the whole-file retry lands on the same name. Sidecar
pages count against the bandwidth cap on both ends, as the setting's
contract says.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Add the replication field to the test harness config

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Check the pause switch after the cap's wait, not before

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
2026-10-05 11:40:19 +00:00
Jamie Pineandcapy-ai-staging[bot] f9bdf724b6 Library join: fix inverted backfill, catch-up loop, reverse device registration, member totals (#3110)
* Networking: serve streams on connections a protocol dials

The connection cache is shared between the dial side and the accept side,
so once a protocol dials a peer, the peer's own send path finds that
connection and opens its streams on it. Nothing read those streams: only
connections the event loop accepted, or those the reconnect loop handed
over, had a stream handler. The peer's request then sat unanswered until
its 30 s timeout while requests in the other direction answered in
milliseconds. This is the reverse RegisterDeviceRequest timeout SPAC-12
saw after join-remote.

get_or_create_connection now hands every connection it dials to the
event loop as TrackOutboundConnection, which spawns the same stream
handler an inbound connection gets. Messaging, sync, and job activity
pass their event loop handle; pairing keeps its own connection handling.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Sync: the device that joins a library is the one that backfills

Every sync service started Uninitialized and backfilled from the first
connected partner, whatever state it already held. When the Mac Studio
joined the MacBook's library the MacBook was the one with a partner in
its device table, so it went Uninitialized -> Backfilling{Studio} and
applied the empty copy's snapshot over its own rows (LWW on the
deterministic default-space UUID). The Studio never backfilled because
the reverse registration had timed out and its device table held only
itself.

A copy of a library created from another device's library is the only
thing that may start Uninitialized. create_library_with_id now writes an
initial_backfill_pending marker into sync.db; PeerSync reads it on open
and starts Ready otherwise. A Ready device never requests a snapshot
(SharedChangeRequest.include_snapshot = false on catch-up) and repeated
catch-up failures no longer escalate to a full backfill. A copy still
waiting for its own first backfill refuses to serve as a join target.

The join carries the rule explicitly: RegisterDeviceRequest has
needs_initial_state, and RegisterDeviceResponse returns the responder's
own device record so the joiner's table is complete when the command
returns, replacing the spawned reverse request. sync_setup fails
instead of reporting devices_registered: true when that record is
missing.

Catch-up completion is recorded per peer in sync.db even when no rows
arrived; the Ready loop catches up once with a peer it has no record
for and no longer re-polls every peer whose watermark is 60 s old. A
missing watermark means never synced, not ancient, so the forced full
sync warning only fires for a real watermark past retention.

library_join_test pairs two daemons for real, joins Bob to Alice's
library, and asserts Alice's rows are unchanged, Bob holds Alice's
device row and spaces, and Alice runs no further catch-ups.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Sync: test that catch-up with an empty peer runs once

Alice holds a volume, Bob holds nothing of his own, so Alice never
writes a per-resource watermark for Bob. Before the per-peer catch-up
record, that made Bob a new peer on every loop iteration and Alice
caught up with him forever (2 -> 5 sessions in 16 s on 5e285d3). The
test asserts the session count stops moving and the record exists.
Both new suites join the xtask integration list.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Library info: fleet totals count the same devices as device_count

add_device_summaries added every cached peer summary to a library's
statistics, whatever library the peer belonged to, and the summaries
carried no source count. A joined device therefore reported the member's
3 million files next to Sources 0 and Devices 1.

Summaries now carry the device's own source_count, and only summaries
of devices in the library's device table are added, so files, content,
sources, and capacity describe the members that device_count counts.
The CLI labels the block "Statistics (all library members)".

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Docs: describe the pairing and library join flow as it runs

pairing.mdx and devices.mdx described a confirmation prompt that does
not exist and pointed at `sd devices list` for pairing status, which
reads the library's device table and does not show a peer until it
joins the library. The CLI overview lacked the global `--device` flag,
and the sync setup walkthrough never named `library sync-setup
--action join-remote` or `--node-id`. All four now describe the flow
SPAC-12 ran, and library-sync.mdx gains sections on joining, catch-up,
and fleet totals. One PROJECT_STATUS register row.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Sync: catch up once per connection session, never serve a snapshot while pending

Review of #3110 found two gaps. The per-peer catch-up record was never
cleared, so a peer that changed rows while disconnected was never caught
up with again after reconnecting (the live broadcast only lives in the
in-memory retry queue). The record is now cleared when the sync service
starts and when the peer's connection is lost; ConnectionEstablished is
not used because the event loop emits it per accepted stream.

The SharedChangeRequest handler attached a full snapshot without
checking the first-backfill marker, so two copies that both joined from
the same member could seed each other. It now refuses the snapshot while
pending, and the register handler reads the marker from sync.db directly
when the library's sync service is not up yet.

Also: a failed backfill or catch-up left active_backfill_sessions
incremented; the mock transport no longer lists a peer as a partner
before its sync service is registered; the catch-up test now starts
Alice Ready (marker cleared) and Bob pending, the shape of a real join,
and asserts exactly one catch-up and one backfill.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Sync: move past a peer that refuses a snapshot, fail the guard closed, refresh stale catch-ups

A joiner whose chosen peer refused the snapshot kept choosing it: every
partner was scored has_complete_state: true and RTT is only recorded on
success, so the order never changed. A peer whose backfill failed now
scores without complete state until a backfill succeeds, so a connected
member holding the library is tried next.

The snapshot guard read an unreadable marker as "holds state"; it now
refuses, since a wrong refusal only delays a join.

A broadcast whose retries ran out while the QUIC session stayed up had
no recovery under the once-per-session rule. The Ready loop refreshes a
catch-up record older than ten minutes; with per-peer watermarks an
empty refresh is two small requests.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
2026-10-05 10:35:00 +00:00
Jamie Pineandcapy-ai-staging[bot] 5e285d3c97 Nightly CLI channel: rolling prerelease workflow, sha-aware self-update, update timer (#3107)
* Add nightly CLI channel: rolling prerelease workflow, sha-aware self-update, update timer

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* TEMP: trigger nightly on PR branch

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Raise the launchd daemon file-descriptor limit to 8192

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Remove temporary nightly trigger for the PR branch

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Nightly: save the rust cache, note why Linux builds on 22.04

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Address review: semver-ordered stable updates, assert nightly target commit, quote systemd ExecStart

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Nightly: skip the xtask daemon build, retry release creation

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* TEMP: trigger nightly on PR branch

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Nightly: remove the runner's libunwind before setup.sh

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Remove temporary nightly trigger for the PR branch

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
2026-10-05 04:25:44 +00:00
Jamie Pine 61dea60558 CI green on sources: runner toolchain, clippy, typecheck, tests compile (#3102)
* ci: install rustfmt and clippy components on every runner

Self-hosted runners have rustc preinstalled, so setup-rust skipped the
dtolnay/rust-toolchain step that added the components and the Rust
Formatting job died with 'cargo-fmt is not installed'. Add the components
to rust-toolchain.toml and run rustup component add unconditionally.

* setup: install FFmpeg development libraries on Linux and macOS

crates/ffmpeg is a default workspace member that links libav* through
ffmpeg-sys-next, so a fresh machine fails the first cargo build with
'libavutil.pc not found'. Install the libav dev packages and pkg-config
from scripts/setup.sh for every supported package manager, warn from
xtask setup when pkg-config cannot find them, and document the requirement
in the README build section.

* test: call the trash directly in delete_strategy_test

bcc7337 gave LocalDeleteStrategy::move_to_trash a JobContext parameter so
it can record where the item went; the integration test still passed one
argument and stopped every cargo test target from compiling. The two trash
cases now call ops::files::trash::trash with the same arguments the
strategy forwards, which is what they were asserting on.

* interface: clear the 30 Tauri typecheck errors

Remove the 23 unused imports and locals, apply the rename rules' serde
defaults (regex and whole_name false, start and step 1) where the modal
renders optional fields, drop the size prop ToggleGroup never accepted,
and type SourceCard on ts-client's SourceInfo so a filesystem source with
no adapter renders its data type instead. Register the /redundancy/duplicates
route the dashboard already navigates to, which is what the DuplicatesView
import was for.

* clippy: make cargo clippy --workspace -D warnings pass on Linux

sd-store names its sqlx row tuples with type aliases. sd-ffmpeg converts
with try_from and f32::from where the cast was lossless, takes raw
borrows for FFI out-pointers, resamples from an integer position, and
allows the pointer-alignment and s32-to-f32 precision casts with the
reason. sd-mobile-core sets DaemonRequest.device (None, local execution)
on the two envelopes it builds. file-opening-macos and file-opening-windows
pull their platform bindings only on that target and gate their sources,
matching sd-desktop-macos. The Tauri build script drops externalBin from
the config when no daemon sidecar exists, so a check or clippy run that
never built the daemon still passes, and the desktop binary's macOS-only
commands return on other hosts without unreachable code. Workspace
rust-version moves to 1.95, the floor gpui already imposes, so the MSRV
lint stops flagging APIs the pinned 1.97.1 toolchain uses.

* docs: make the stale doctests compile

sd-media-metadata examples still imported from the old image module and
asserted a plus code that belonged to the other example's coordinates (the
value is verified against the reference Open Location Code encoder);
MediaLocation has no Display, so the parse example compares coordinates.
The sd-task-system example unwraps dispatch and matches the (TaskId,
TaskOutput) pair Done now carries. The spacedrive-sdk sketch names types
that do not exist yet and the job-derive expansion only resolves inside
sd-core, so both are marked ignore with the reason beside them.

* build: pin bun to 1.3.4 everywhere

package.json said bun@1.3.0 while CI installed 1.3.4, and any newer bun
rewrites bun.lockb on install, so the lockfile churned on every machine.
Pin packageManager to the CI version, document it, and install with
--frozen-lockfile in the TypeScript job so a drifting lockfile fails
instead of being silently rewritten.

* status: register the CI green on sources work and the libav requirement

* docs: make the sd-core doctests compile too

Five sd-core examples referenced the crate by its old name, used an arrow
glyph in a rust fence, or called methods without the trait in scope; the
copy strategy sketch needs a running JobContext so it is marked ignore.

* ci: stop shrinking the runner's root disk before installing a toolchain

easimon/maximize-build-space moves the root filesystem's free space into an
LVM volume mounted at the workspace and leaves root with the reserve, which
on the Blacksmith runner came out at 100 MB after the swap file. rustup
installs the pinned 1.97.1 toolchain under /home/runner/.rustup on root, so
both Rust jobs died with 'No space left on device'. The runner has 82 GB
free on root, which is plenty for a clippy target directory, so the step
goes.

* ci: run clippy on the Ubuntu 24.04 Blacksmith runner; let sd-server check without bun

The Blacksmith migration wrote the runner label into the matrix's target
field and left runs-on at GitHub's ubuntu-22.04, whose FFmpeg 4.4 predates
the libav 6 API sd-ffmpeg uses (ch_layout, av_dict_iterate), so the job
could never compile sd-ffmpeg. It now runs on blacksmith-4vcpu-ubuntu-2404
with FFmpeg 6.1, the same as a developer machine on 24.04, and the target
field holds a target triple again.

sd-server's build script skipped the web build when bun was absent and
RustEmbed then failed on the missing apps/web/dist. A debug build now gets
an empty dist with a warning so check and clippy pass; a release build
without bun and without a prebuilt dist still refuses, so a server never
ships without its UI by accident.

* build: let the desktop crate compile without a built frontend

tauri::generate_context! panics when frontendDist is missing, which is how
every check or clippy run looks before bun run build. The build script now
creates an empty apps/tauri/dist for debug builds with a warning; a release
build without one still refuses.

* status: record the CI result for the sources green-up
2026-10-04 20:15:07 +00:00
Jamie Pine 9929d22c72 Land MacBook work: sources.track fix, source hashing test, addressing and locked-volumes plans 2026-10-04 01:41:31 +00:00
Jamie PineandClaude Opus 5.5 fec139fbdf Remove Spacebot and the voice overlay
Spacebot ships separately. This drops its UI, the voice overlay and the
Alt+Space shortcut that opened it, the spacebot app config and window, the
@spacebot/api-client and @spacedrive/ai wiring, and the Spacebot and
Console design docs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 16:30:43 -07:00
Jamie Pine 3cd589bc87 Regenerate the Tauri capability schemas
Key order only, from a build.
2026-09-25 15:42:32 -07:00
Jamie Pine 4e78b40e8e Follow folders on other devices in Photos and preview them in Quick Look
Photos takes a focus on another device's folder and lists it through search.media. Cells keep their SdPath, so thumbnail requests go to the device that owns the files. Quick Look on a cell asks the data plane for the path on this machine: its own for a file here, answered in process, and its path inside the mounted share for one elsewhere, from files.local_path. Only the latest answer reaches the panel, so a cursor that has moved on is not pulled back. The device slug no longer holds a listing back.
2026-09-25 15:42:22 -07:00
Jamie Pine e752cb460b Preview and open files from other devices in the desktop app
Quick Preview reads every original through useOriginalUrl: the asset protocol for a file on this device, and files.stream_url for one on another, so a remote video plays and seeks without a download. It replaces five copies of the URL code across the renderers and the mesh viewer. Open and double-click hand another device's file to the opener at the path files.local_path returns. Open With, Show in Finder and Share, in the menu and in the inspector, take only files on this device, since another device's path names a different file here or none. The CSP allows media from loopback.
2026-09-25 15:42:19 -07:00
Jamie Pine a733bca637 CLI: rename, undo, mirror, organize, flatten, archive, extract, attributes, link, trash
sd file rename (--to or rules), undo, organize, flatten, archive, extract, attributes, link and trash list|restore|empty, plus sd job journal and --remove-extras on merge. Every one shows its findings and plan before the prompt through one run_with_preflight.
2026-09-23 22:19:45 -07:00
Jamie Pine e1e73f2b38 Dedupe as a delete target: first copy or chosen copies, read in full before removal
files.delete takes a Duplicates target: a scope with keep first (walk order) or a list of copies to keep with every other copy of their content going. The job reads each surplus copy and its keeper in full before removing, records the hashes it learns, and checkpoints the compare cursor so it resumes. Preflight validates the scope and keep list and previews keepers as skips and surplus copies as deletes.

Store gains duplicated_contents_beneath and copies_beneath. files.duplicates is registered as a core query and reports each copy's SdPath. CLI: sd file dedupe with --keep, --keep-under, --min-size, --dry-run.
2026-09-23 20:07:53 -07:00
Jamie Pine 3cd44f2daa Add preflight to actions and build folder merge on it
Two opt-in methods per action, validate: and preview:, over the exact
input the action takes. Validation answers findings with stable codes and
the facts of the execution, runs again at dispatch and refuses on an
error. Preview answers an FsPlan read from the index, kept under a handle
so files.directory_listing can serve the directory as it would look after
the operation.

Copy, move, merge and delete answer both. Merge is new: a resumable job
that recurses into matching folders, reads both sides before skipping a
duplicate, applies a per-leaf conflict policy, leaves conflicts alone and
can consume its sources. Delete's validation counts, across every store,
which files are the last copy of their content.

The ValidationResult and ConfirmationRequest hook is gone, and so is the
CLI's local conflict detection. file copy, merge and delete render
preflight before the prompt and take --dry-run.
2026-09-23 17:44:24 -07:00
Jamie Pine 903c8f2110 Delete from a folder what a comparison names
files.delete takes a comparison as its targets alongside a path list, so
the client sends five fields and the daemon derives the set from the
index as the job runs. The job drains the matcher the compare query
pages, checkpoints its cursor after each batch, and for `both` reads
whichever side lacks an integrity hash before removing a file, writing
what it learned back to the store. Pairs whose bytes differ, or whose
copy in B is gone by then, stay and are reported. The CLI gains
`sd file delete A --against B --show both` behind a y/N prompt, plus the
plain paths form.

Compare matched on content uuids, which re-derive from the integrity
hash once a copy is verified, so a copy verified on one side stopped
matching its unread twin. It now matches on the sampled hash every store
keys a content by, with integrity hashes deciding where both sides have
them; FsEntry carries both hashes for that.
2026-09-22 13:00:01 -07:00
Jamie Pine 16325fc5c1 Name compared folders A and B, and their sets after a Venn diagram
Left and right only meant first and second argument, and nothing on
screen said which folder was which. "Same" meant different things per
mode, "changed" implied a direction compare can't know, and "location"
collided with the retired locations.

The output now names both folders as A and B up front, says how files
matched, then counts Only in A, Only in B, In both, and Different (by
path only). --by location becomes --by path and the --show values
follow, as do the API types, generated TS types, skill, and docs.
Single-side listings get Size and Modified columns under a title naming
the set, and --help explains each value.
2026-09-22 04:47:40 -07:00
Jamie Pine ac49fc1f09 Walk every page of a folder comparison in the CLI
files compare printed one page and handed back an --after cursor to
paste. The CLI now follows the cursor itself: totals first, then each
set's files streaming one per line as pages of 5000 arrive. --limit
caps the total and says N of M listed when it cuts a set short, a
closed pipe like head ends the walk quietly, and --format json emits
one document holding the totals and every entry. The skill doc
follows.
2026-09-22 04:21:56 -07:00
Jamie Pine e5e6749c26 Compare two folders over the source stores
paths.compare answers what differs between two folders from the source
stores at query time. By location it merges both sides in index order
into only-left, only-right, changed and same. By content it streams one
side and asks the other side's stores which content ids they hold, so a
renamed or moved copy still matches. Pages resume from a cursor and the
first page carries every set's count. Unhashed pairs fall back to size
and mtime and get counted, so a page says how much rests on that.

sd-cli file compare wraps it.

On the store side, files_beneath takes a Start, including one that
resumes a nested store at its first directory, and contents_beneath
answers the content lookup a batch at a time. Working out which stores a
path reaches moved into ops/paths/reach.rs, shared with search.media and
paths.context.

No catalog.db: the P7 row, the filesystem-intelligence plan, the previs
and the design docs now treat cross-source questions as store queries.
2026-09-22 02:48:34 -07:00
Jamie Pine b16816b73b Page Photos from the source stores and follow searches
Photos listed only a folder's direct children and never saw the explorer's
search, so searching from a folder of folders showed no photos.

search.media pages images and videos straight from source SQLite. Files come
out in the order the directory_path and sibling indexes already keep, so a
page reads its own rows and resumes from a (source, directory, name) cursor.
Extension, kind and hidden narrow in SQL; name, size, date and tags are
judged per row the way the store backend of search.files judges them. A
folder reaches its innermost source and any sources nested beneath it.

The navigation focus now carries the running search, and Photos lists it in
place of the folder. Folders list everything beneath them. Pages grow the
grid as they land, and tile identities name their drive's cache, so a
library-wide search across drives renders.
2026-09-22 01:24:05 -07:00
Jamie Pine 8f12892c12 Let a daemon start without the default places
The first client to connect starts automatic discovery: it tracks the
system volume, adds the home folder as a source and walks every attached
drive. For a library meant to hold only what you add, like one whose data
directory lives on the drive it catalogs, that indexes the Mac into it.

sd start --no-default-sources, and the same flag on restart and on
sd-daemon, now leaves those three out. The pass still restores snapshots,
heals coverage for sources already tracked and hashes them, so a source
you added keeps working across restarts. sd-server keeps the defaults.
2026-09-21 21:31:47 -07:00
Jamie Pine 0062a34d58 Open Quick Look outside gpui's app borrow
Opening the panel or handing it a new file loads the file before the call
returns, and the panel spins the event loop while it waits. Called from a
key action, that let gpui's main-thread tasks run while the action still
held the app, and the grid's frame watchdog panicked on the second borrow,
which aborts inside an FFI callback.

Requests now record the wanted file and the panel catches up on a later
turn of the main thread, with no gpui or RefCell borrow held across a
panel call. Posting real key events reproduced the abort on the old code
and runs clean now: Space opens, arrows follow, Esc closes, Space reopens.
2026-09-21 19:24:33 -07:00
Jamie Pine e89141a4c6 Preview originals in Photos with Quick Look
Space opens the photo under the cursor in the system Quick Look panel, so
HEIC, RAW, and video open at full size straight from the file. Space or Esc
closes it.

A controller hung off the window's next responder takes control of the
panel and serves it the one file on show. Keys the panel doesn't use go
back to the gpui view, so the arrows still move the cursor and the preview
follows, and the number keys still tag in tag mode. On drop the controller
leaves the chain and the panel looks again, so it never holds a controller
that's gone.
2026-09-21 19:02:06 -07:00
Jamie Pine 338e052ca5 Add selection and tag mode to Photos
Photos now selects the way the explorer does: click, cmd-click to toggle,
shift-click for a run, cmd-A, arrows, Esc to clear. T opens the same tag
bar the explorer has, with the library's first ten tags on the number keys.
A key takes its tag off the selection when every selected photo carries it
and puts it on all of them otherwise. Tagged tiles get dots in the corner.

The listing now carries each cell's record and tags, and the plane follows
the file rows the daemon announces. Indexing announces rows without their
tags, so a row naming none is believed only for records this window is
changing and read back with files.by_id otherwise. Tag changes queue on a
per-folder channel from the moment the folder is listed.
2026-09-21 18:53:21 -07:00
Jamie Pine 90e2f3ba3a Make Apps > Photos open its window
Nothing built spacedrive-native, so the Apps menu looked beside the desktop
binary, found nothing, and logged the error to the dev terminal only.
just dev-desktop now builds sd-native first. A failed build only warns,
since sd-native is not a default member and can trail core.

A launch that fails now shows a dialog. Photos quits when its window
closes, and launching it while it runs brings its window forward through
NSRunningApplication, which the launcher's doc already claimed it did.
2026-09-21 17:44:48 -07:00
Jamie Pine 4e58ddca55 Publish device facts over the peer plane
Paired devices answer a new DeviceFacts request with their hardware and
mounted volumes, under the ids their sources anchor to. The peer fetches
them on every sync beside the device summary and keeps them in the replica
manifest with its own receipt time, so an offline device still describes
itself as it last did.

volumes.list lists those volumes under their owner, mounted only while it's
connected, and devices.list fills in a paired device's hardware. The
overview folds replicas into their owner's volume bars the way local
sources fold into theirs. Another device's volumes show when they were last
seen and hide the local volume actions on desktop and mobile.

Volume classifications travel as names, so one a peer's build doesn't know
reads as unknown instead of failing the response.
2026-09-18 18:08:47 -07:00
Jamie Pine 8a878a3c9b Clear location and entry residue from the code
The error variants, the permissions flag, the sync fixtures, and a long
tail of comments still named locations and entries after both left the
product. LocationNotFound goes from the query, action, and CLI errors;
can_manage_locations becomes can_manage_sources; ChangeConfig's
location_root becomes root; and the sync tests key their watermarks,
checkpoints, dependency parsing, and messages on models that still sync.
The transaction manager's stubbed pre-HLC methods and the bulk operation
types only they used are removed, and the spaces invalidation arm stops
matching a location resource nothing emits.

File transfers and deletes describe their allow-list as the source roots
it is. The CLI no longer points a new cloud volume at sd location add. In
the clients, mobile loses its location search scope and empty Locations
section, an unused overview card mock is deleted, and empty states and
copy say folder, place, or source instead of location.
2026-09-18 01:22:33 -07:00
Jamie Pine cc5ffc15d3 Retire the ephemeral qualifier
The word marked a contrast with the persistent entries index, and that
index is gone. ops/indexing/ephemeral folds into ops/indexing, and the names
now say what each piece is, keeping the in-memory arena apart from the
durable registry and stores:

  EphemeralIndexCache, ephemeral_cache()  ->  VolumeIndex, volume_index()
  VolumeIndex (one drive's slot)          ->  Partition
  EphemeralIndex                          ->  Arena
  EphemeralEventHandler                   ->  FsEventHandler
  watch_ephemeral / unwatch_ephemeral     ->  watch_root / unwatch_root
  IndexerJobConfig::ephemeral_browse      ->  IndexerJobConfig::new
  set_ephemeral_index, from_ephemeral     ->  set_arena, from_arena
  ops/search/ephemeral_search             ->  arena_search
  core.ephemeral_status / ephemeral_reset ->  core.index_status / index_reset

The CLI's index subcommands become status and reset. Status output loses
its always-empty legacy fields, EntryRef loses an id that was always zero,
the indexer output loses a field nothing read, and module docs that
described browsing outside managed locations now describe the volume
index. On-disk snapshots keep the ephemeral.snapshot name: renaming the
file would orphan every snapshot and re-walk every drive. The job system's
ephemeral jobs, Spacedrop's ephemeral shares, and UI state that is never
persisted keep the word, since it means what it says there.
2026-09-18 01:10:39 -07:00
Jamie Pine 9374855db2 Retire the persistent-era search and event surface
Search reports one index, so IndexType and the index_type field are gone,
along with the Locations filter kind, the locations filter and facet, the
constructors that only existed to stamp Persistent, and the Entry-based
enhanced output. Tags and hidden files are advertised filters with no
caveat, since both backends apply them.

Fifteen Event variants that nothing emits are removed: the Entry events,
FsRawChange, the location-keyed Indexing events, and the legacy location,
thumbnail, file-operation, and files-modified events, with their helper
types. events.list now derives its names from the enum instead of a list
that had drifted from it. The CLI printer, the Tauri and ts-client default
subscriptions, and the client-side filter types follow, and an uncompiled
stale copy of type_extraction.rs goes with them.
2026-09-18 00:26:41 -07:00
Jamie Pine 318ab3891a Replace the library schema and remove locations
FD4 of the final entries drop. One migration drops 29 retired tables inside
its own transaction, children before parents: with foreign keys on, dropping
a parent makes SQLite parse every child's constraints, and the first attempt
failed on sidecar, whose other parent entries was already gone. That attempt
also showed Migrator::up leaves SQLite migrations unwrapped, which is why
the migration opens its own transaction. Every library now converges on 14
tables. Proven on a copy of the live library, on a library the pre-drop
build left half-migrated, on a brand-new library, and on the live Mac
library after a backup; every dropped table held zero rows there apart from
the seeded kind lookup.

Locations leave with the schema: ops, domain type, entity, CLI domain, and
every consumer in the web and mobile clients. PathBar pins become
space-level Path items listed under Pinned, the sidebar's Places come from
a new paths.system_folders query, Add Storage tracks sources and validates
through sources.validate_path, device views list sources, and library
statistics count sources as source_count. Content kind statistics read the
source stores instead of a lookup table.
2026-09-17 23:09:48 -07:00
Jamie Pine 7d81966c34 Move tags onto source stores end to end
tags.create stages a definition in library.db until first application adopts
it into a store; apply resolves targets through the arena with the stores as
the floor, so a cold daemon and a detached source both answer. Every claim is
stamped by a real HLC seeded from DeviceManager; the library-id-as-device
placeholder is gone. File targets carry the content key when the store knows
it, so tagging a copy reaches the bytes without the client choosing.

Writes against a remote-owned source author their rows durably in an
assertion outbox and deliver to the owner over remote ops when it answers:
sources.assertions.merge commits before replying, the reply is the ack, and
redelivery is absorbed by the assertion primary key. Pending claims overlay
replica listings immediately.

Reads decorate from the stores everywhere: directory listings on both the
arena and store paths, files.by_id, files.by_path, replica listings, and
files.by_tag, which no longer needs a loaded arena. File::announce decorates
its payload too, which is what makes tagging reactive: the event used to
carry empty tags and the client faithfully rendered that.

Deleted: TagManager, TagClosureService, TagConflictResolver,
TagUsageAnalyzer, TaggingFacade, UserMetadataManager, ops/metadata, the
validation module, and the semantic-tag field zoo. Tag is uuid, path, name,
color, icon. The XOR-era UI gates (the needs-indexing toast and the
inspector's isEphemeral message) are gone; the one honest refusal left is a
path outside every tracked source. The CLI gains tag unapply and delete. No
data migration: the legacy tables were verified empty, and their entities
wait for FD3 and FD4.
2026-09-17 20:03:34 -07:00
Jamie Pine 25fd868a2f Add path-scoped thumbnail generation
From a parallel session. thumbs.generate accepts a file or an indexed
folder with missing, stale, and force modes, runs in resumable batches,
and versions video tiles against host FFmpeg availability so a tile
regenerated with better tooling replaces its predecessor in place. File
and folder context menus and the new sd thumbs CLI domain call the same
operation. paths.context now reports whether the exact path has a
committed source record, and the path bar surfaces it with a refresh
affordance. Coverage and resume semantics are documented in
docs/core/thumbnail-generation.mdx.
2026-09-15 21:37:35 -07:00
Jamie Pine b653676e80 Docs, register, and interface polish from parallel sessions
README and CONTRIBUTING updates, the working register brought current,
locations and data-model pages reconciled, redundancy and overview
polish, media view tweaks, regenerated schemas, and the new Spacedrive
icon asset.
2026-09-15 17:09:40 -07:00
Jamie Pine c32768f2e4 Make startup discovery explicit and permission-aware
Library loading no longer dispatches a filesystem discovery pass. The
desktop requests one idempotent pass after the connected shell
renders, a bare daemon initiates nothing, and sd index start
--defaults gives CLI and server operators the same explicit path.
Restored volume snapshots prevent repeat full walks.
2026-09-15 17:09:40 -07:00
Jamie Pine 4e53d58dbb Keep baked thumbnails through remounts, fix Quick Preview originals
Hot-tier bake completion epochs survive virtualized cell unmounts, and
a thumbnail hides its fallback only after the current image element
loads its current URL. The Tauri asset boundary maps canonical macOS
APFS data-volume paths back to the scoped /Users and /Volumes aliases
so originals load without a 403, with tests over the alias mapping.
2026-09-15 17:09:30 -07:00
Jamie Pine f3a1ede2f8 Discover host media tools, bake video thumbstrips on demand
A machine-scoped registry discovers host FFmpeg and companion tools,
surfaced in Services settings. The thumbnail service uses them for
video posters and on-demand scrub sheets: thumbstrips bake per request
into the volume hot tier and serve over the local HTTP servers with
immutable content-versioned URLs, wired to the timeline scrubber.
2026-09-15 17:09:30 -07:00
Jamie Pine f0347a3179 Fix hot-thumb 404s and silence browse indexer jobs
The Tauri hot-thumb handler still read sources/<id>/thumbs.pvcache
after the cache moved to volumes/<id>/, so every tile request 404'd.

Directory browsing dispatches an ephemeral indexer per first visit;
since the entries teardown dropped the ephemeral persistence guard,
those jobs persisted a row and emitted start/completion events, so the
desktop app played the job-done sound on every navigation. Browse jobs
now run in the background: the listing already reaches the UI through
ResourceChanged events, and completion is marked inside the job, not
from its events. User-initiated indexing and volume mapping keep their
events.
2026-09-15 02:07:21 -07:00
Jamie Pine b1b0d28b72 Refresh project context and working registers
README rewritten for the 2.0 release framing. AGENTS.md points at
product-direction.mdx for the product boundary and makes
PROJECT_STATUS.md the project-wide working register (the entries
teardown table stays authoritative for that track). CLAUDE.md and
PROJECT_STATUS.md are now tracked. Spacebot VISION marked retired.
justfile gains dev-docs/build-docs and splits dev-desktop-watch out of
dev-desktop. Tauri gen schemas regenerated.
2026-09-15 00:59:41 -07:00