Commit Graph
1653 Commits
Author SHA1 Message Date
Jamie PineandClaude Opus 5 f5bf69d60f Back the node arena with an unnamed temp file
NodeArena mapped a NamedTempFile, which is removed only when Drop runs. A
daemon that is killed never runs it, so every exit left each arena's file
behind in the temp directory: about ten per start, plus one per replica
rebuild. Titan's /tmp is a tmpfs with no swap, so 1,949 of these held 9.4 GB
of RAM that nothing could reclaim, leaving the daemon under 2 GB free.

tempfile() gives the file no name, so the kernel frees it when the process
exits however it exits. Mapping, growth and paging are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 16:41:10 -07:00
Jamie PineandClaude Opus 5 4d18db0ab3 Bound thumbnail decode workers by free memory, not just cores
bake_workers sized the pool from core count alone. Titan has twelve threads
and gives ZFS its ARC nearly all of 31 GB, leaving under two gigabytes free
in practice, so eight decodes bursting together took the daemon past what the
box had and the kernel killed it (exit 137) twice during a footage pass.

A worker now needs a gigabyte of free memory to exist, which is the ceiling
one 4K decode plus its reference frames fits under. Hosts with memory to
spare still get the core-derived count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 06:06:53 -07:00
Jamie PineandClaude Opus 5 bf1ca21a19 Scale video frames before selecting one for a thumbnail
The thumbnail filter holds a window of frames to pick a representative one
from. Running it ahead of the scale meant that window was 30 frames at source
resolution, so one 4264x2408 ProRes file peaked at 1,290 MB. With 32 of these
in flight the daemon exhausted a 31 GB box and was killed mid-job.

Scaling first drops the peak to 465 MB for the same output, and a single
decode thread takes it to 233 MB and runs a second faster, since the batch
above already spreads work across files.

Measured on titan against /mnt/pool/footage:

| recipe | peak RSS | time |
|---|---|---|
| thumbnail=30,scale | 1,290 MB | 5.67s |
| scale,thumbnail=30 | 465 MB | 5.80s |
| threads=1, scale,thumbnail=30 | 233 MB | 4.15s |

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 02:40:40 -07:00
Jamie Pine e053bf233e Build the file type registry once, not once per arena insert
Rebuilding a replica's arena from a delivered database adds rows one at a
time through Arena::add_entry, and that method built a FileTypeRegistry per
call. Building one parses every built-in definition: 92 KB of TOML across
15 files and 349 types, about 3 ms. That was the whole cost of an insert.

Titan spent about 57 minutes rebuilding the Mac's 1.76M-entry home source
after each transfer. The database was never the problem: the full join scan
is 3 s, and one 2,000 row page is 7 ms against an optimal plan.

The built-in set never changes after startup, so one instance serves the
process. 20,000 inserts drop from 60 s to 143 ms, 3.00 ms to 7.2 µs each,
which puts that rebuild near 13 s in a debug build. The batch path built one
per batch and now shares the same instance.

core/tests/arena_insert_cost.rs fails if an insert starts rebuilding it.
2026-09-19 21:54:59 -07:00
Jamie Pine 087199fc67 Version nested source replicas by store revision
A nested source's generation came from the size and mtime of data.db and
its WAL. SQLite deletes the WAL when a pool's last connection closes and
recreates it on the next read, and sqlx closes idle connections after 10
minutes, so the generation moved without any row changing. Titan re-copied
the Mac's unchanged home store in full four times overnight, each round a
736 MB transfer and a 57-minute arena rebuild.

Stores now keep a _revision row, a store id and a count of committed
changes, maintained by triggers on every tracked table. Updates that change
nothing, or only restamp scan_epoch or indexed_at, don't count. Listings and
database headers use the revision instead of file metadata.

The triggers put each single-row store write in a statement journal: the
300k-file scale test builds in 16-18s instead of 12-14s. temp_store=MEMORY
brings it to 9-10s but moves large sorts on the writable pool into memory,
so that's an open decision in the new incremental replication plan.

Verified live: after one switch-over copy, titan made no transfer for twelve
minutes, including across a Mac daemon restart.
2026-09-19 02:09:42 -07:00
Jamie Pine d346c63443 Redial paired peers that never connected this session
Every paired device loads as Paired, and the periodic reconnect only dialed
devices in the Disconnected state, which a device reaches only after a live
session drops. A peer that was down when the daemon started stayed Paired
after the startup attempts ran out, and since the higher EndpointId side
waits to be dialed, the two never reconnected until one restarted. The
periodic pass now dials Paired devices too.

Verified with titan down across a Mac restart: the Mac reconnected 20
seconds after titan came back, with no restart.
2026-09-18 20:31:19 -07:00
Jamie Pine ec1ff66400 Use mimalloc as the daemon's allocator on musl
Titan's daemon aborted overnight on a failed 53 KB allocation with memory
to spare. musl's allocator gives each allocation group its own mapping, and
the daemon reached 45,641 of the kernel's 65,530 maps and 10.5 GB RSS
within 43 minutes of a restart. With mimalloc as the global allocator on
musl builds it holds about 260 maps and a flat 1.7 GB. Other targets keep
the system allocator.

The register also records that titan's start command was writing stdout
into its own jamie-nas source, which re-hashed the log every 30 seconds;
stdout now goes to the excluded data dir.
2026-09-18 20:31:09 -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 eff9ee928b Deliver the test replica the way the owner does
The replica restore test copied data.db after closing the pool and relied
on the close checkpointing the WAL. When any connection outlived the close,
the copy was a bare header and the restore read it as malformed, about half
the time. The owner exports with VACUUM INTO, which writes one consistent
file, so the test now does the same.
2026-09-18 01:11:42 -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 bdc8c61e96 Remove indexer state that served persistent entries
The walk no longer creates entries, so the UUIDs it carried forward for a
browsed folder promoted to a location, the entry-id map of existing rows,
and the progress flag that was always false served nothing. Two root
finders in the responder and a volume file-count helper had no callers.
2026-09-18 00:32:41 -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 44e65c2af1 Stop reading tables the schema drop removed
Two raw-SQL readers survived the drop because the compiler cannot see a
table name in a string. The statistics refresh kept a denormalized count
in content_kinds and logged a failure every five seconds; kind statistics
already sum the source stores, so the refresh is gone. The redundancy
summary grouped entry rows by volume. Redundancy is not computed over
source stores yet, and an empty summary would read as nothing at risk, so
the query now says so instead of failing on a missing table.
2026-09-18 00:08:10 -07:00
Jamie Pine b38e7deb21 Let a change land in a browsed directory that was empty
A walk that finds nothing writes no entry for its root, and nothing under
it synthesizes one, so the watcher's depth check climbed past the watched
root and dropped every change. Dropping a file into a browsed empty folder
never reached the index or the client. The watched root is held by
definition, since it was walked to completion; the writer creates its
entry with the first change that lands there.
2026-09-18 00:07:39 -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 8819abb809 Remove legacy row sync for the retired models
Fourteen entry-era models lose their sync registrations and Syncable impls:
entry, content_identity, sidecar, mime_type, the three media_data tables,
collection, collection_entry, location, tag, tag_relationship,
user_metadata, and user_metadata_tag. Entry's apply_state_change, tombstone
gating, and closure and directory-path rebuilds go with them, as do
backfill's entry hierarchy sort with its per-record self-referential FK
resolution and the sync helpers' directory-path payloads.

The engine survives for what still syncs: device, volume, space,
space_group, space_item, and audit_log, over the same transport, HLC,
watermarks, and dependency tracking the library-sync research plans to
evolve. A registry test now refuses any retired model that tries to
register again. Live capability audit passed: replica sources listed,
remote ops answered by titan, replica listings served, remote job
reporting responsive.
2026-09-17 21:12:41 -07:00
Jamie Pine 8eb34e98fb Drop the runtime entry readers
FD0 and FD1 of the final entries drop. The baseline is recorded in the
plan: the reference inventory, the 43 tables a fresh library still
creates, the registered sync models, and the pre-existing entry-era test
failures, with the live fleet standing as the fixture.

File::route_from_dependency is deleted rather than rewired: the only
emitters of file dependency events were legacy row-sync applies, and file
payloads have announced directly from the volume index since the tags
work. The ancestor entry-ID cache, the dead entries_for_content field, and
the SQL path resolver leave the indexer; pairing discovery totals read
source rows, with sources standing in for the retired location count; and
the indexing module docs stop describing a writer that no longer exists.
2026-09-17 20:53:22 -07:00
Jamie Pine 3dc6386f4e Retire the last legacy tag readers and set the docs straight
Library statistics counted the legacy tag table, which would have read zero
forever; the instance count now reads definitions across staging and the
stores, and the pre-open static count reads staging with the instance
recount behind it. tagging.mdx describes the built system instead of the
old design sketch, library.mdx stops claiming the library owns tags, and
the design doc carries its three reconciliations. The plans and register
record stretch 1 closed: delivery verified against titan, FD4's
preconditions banked, phase 4 landed.
2026-09-17 20:42:21 -07:00
Jamie Pine ff613135ec Resolve tag filters at the stores and advertise them
A TagFilter resolves once per query: include tags intersect to the paths
carrying all of them, exclude tags union, both absolute in store-root
spelling, checked at collection on the scoped path, the library-wide
fan-out, and the store fallback alike. Replica partitions drop out while a
tag filter is active, because their assertion state lives with their owner
and passing their hits through unfiltered would be a wrong answer rather
than a degraded one.

Ephemeral results now advertise the Tags filter, and the refinement bar
gains a tag pill driven by tags.search with a color dot per tag.

Proven live: a seven-hit query narrowed to exactly the tagged file on
include, six hits with it absent on exclude, and a 1396-hit replica-heavy
query returned zero under the filter.
2026-09-17 20:42:21 -07:00
Jamie Pine 7ad3da306a Deliver outbox batches by their wire method, and let presence beat backoff
The registry keys actions by their full wire method, so calling the merge by
its bare name got Unknown method from every daemon that could have taken it.
The outbox now names the method through the Wire const on the input type,
which cannot drift.

Backoff measures absence, so a device connecting is information the schedule
has to yield to: drain_for delivers that device's rows immediately, whatever
their next attempt says, while ticks keep respecting backoff for everyone
else. Enqueue kicks a forced drain for its own targets the same way.

Verified live end to end: ten batches authored against titan while its old
server was wedged, including nine from UI testing, delivered on reconnect
after the redeploy, acked, and retired. Titan's stores now carry the
definitions and answer files.by_tag with the tagged files.
2026-09-17 20:31:56 -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 5f1fdd287d Deliver a nested source's replica as its own database
A nested source's arena snapshot is its volume's partition, which
carries sibling paths that are not the source's to share and a
generation that moves when siblings change. The owner now advertises
such sources as nested, exports exactly their records through VACUUM
INTO on the read-only handle, and serves the export with the same
header identity as a snapshot: generation from the live database and
WAL, blake3 of the delivered bytes, length. The receiver validates the
artifact by opening it read-only and rebuilding an arena from its rows
before anything is replaced, then serves the share identically to a
snapshot-built one; browse, search and byte routing do not know the
difference. Restores prefer a snapshot and fall back to the database,
and an owner too old to export one still answers on the snapshot path.

The store read layer's projection grew the remaining facet columns and
a paged full scan for the rebuild, moving to a named row struct.
2026-09-16 05:00:10 -07:00
Jamie Pine 4cf25c17c3 Route source reads to SQLite when no arena answers
One backend per source per request. A loaded arena answers when its
partition restored or a walk this session covers the scope; otherwise
the source's store answers directly through the read-only handle, and
an empty result from whichever backend was selected is final. Library
search now enumerates the source registry past the loaded partitions,
scoped search falls back the same way, and a detached source whose
snapshot did not cover a directory lists it from its store instead of
returning nothing.

Store hits build the same File contract (from_store_entry mirrors
from_ephemeral's name split, kind mapping and content kind), pass the
same filter set failing closed on what a row cannot prove, and score
through the shared function so ranking is backend-invisible. The
equivalence test runs one capture through both and asserts identical
matches, filters and scores, accents included. Search output carries
total_is_exact so a capped store scan cannot silently pose as a
complete count.
2026-09-16 04:50:14 -07:00
Jamie Pine 4a690c84aa Pace dirty-driven replica refetches instead of transferring every pass
A dirty listing over an unchanged artifact forced a transfer on every
refresh interval, and a machine that keeps writing never stops being
dirty: titan was pulling the Mac's 116 MB home artifact once a minute
while a build churned the tree. A moved generation still transfers
immediately; dirtiness alone now waits out a five minute pace anchored
on when the replica last synced. When the owner goes quiet the last
fetch lands the final state and the generations converge to silence.
2026-09-16 04:07:02 -07:00
Jamie Pine 3ee5449bff Let a source registration earn its watch without a fresh walk
register_for_watching required the exact path in indexed_paths, which
is session state: a partition restore re-lists its volume root but not
a source nested inside it, so a nested source's watch never survived a
restart. The registration is the durable evidence the root deserves a
watch, and events over a sparser arena still file correctly through
ancestor synthesis. The volume-watch fallback also skips roots that
are already watched instead of logging them a second time.
2026-09-16 03:52:20 -07:00
Jamie Pine 847144ba0b Fall back to per-source watches when a volume watch fails
A restore announces its volume root, and one unreadable directory
anywhere under it fails the whole recursive watch. On titan the pool
volume watch dies on the root-owned ix-applications/docker directory,
which left dev-tools, the one source nested inside that volume, with
no watch at all. When the volume-root watch fails, each attached
registered source under it now gets its own watch; a nested source
does not lose observation to a sibling it does not contain.
2026-09-16 03:40:06 -07:00
Jamie Pine 8c8499413c Restore registered sources at startup so their watchers arm
Watchers for restored sources arm off the restore announcement, and
nothing on a headless daemon was left driving those restores: a peer's
snapshot fetch used to, incidentally, because the generation mismatch
bug forced a refetch that saved and restored each partition. Fixing the
mismatch removed that churn and left every registered source unwatched
until the first local read, with the cctv cameras writing into an
unwatched tree in the meantime.

The watcher service now restores every attached registered source when
its handlers come up. The subscription is installed synchronously
before the restore pass spawns, so no announcement can be missed.
Detached sources stay lazy; there is nothing to watch until they
return.
2026-09-16 03:21:33 -07:00
Jamie Pine 98e8a1485c Tie replica generations to the bytes actually delivered
The owner saves live state inside FetchSnapshot, so the artifact it
serves is routinely newer than the listing that prompted the fetch.
The receiver was recording the listing's generation for those newer
bytes, guaranteeing a mismatch and a useless re-download on the next
pass. The snapshot header now carries the generation and blake3 of the
open file being streamed, and the receiver records those.

Also on the receive side: a delivered artifact must parse as a
snapshot before it replaces one that already does (previously the
rename happened first, so a corrupt transfer clobbered a good replica),
a failed transfer cleans its temp file and no longer aborts the whole
device sync, per-source fetch failures back off up to 30 minutes, and
an unchanged generation still refreshes the owner's attachment, counts
and label instead of freezing them with the artifact.

Revoking a device now unloads its shares, drops its summary from fleet
totals, and moves its artifacts to mounts-revoked instead of serving
them forever. Snapshot decode is bounded relative to the compressed
size so a corrupt stream cannot grow the buffer without limit.
2026-09-16 02:51:01 -07:00
Jamie Pine 5a65d09982 Heal registered sources whose map coverage went missing
The volume map summarises registered source subtrees, and only the
source's own walk fills them. An interrupted walk, a lost snapshot, or
a nested source whose volume restored around it all leave one shape: a
store holding records under a root whose map node has no children, and
nothing ever repaired it. Three live incidents in one day wore it: the
dev-tools nested source served an empty replica for days, the home
source showed a single folder, and both stayed that way across every
restart because the snapshot faithfully persisted the hole.

The discovery pass now checks every registered source on each attached
volume, restored or not: records in the store with no children in the
map dispatches the source's walk. Store evidence contradicting arena
emptiness is the trigger, so a genuinely empty source is never
rewalked. The walk dispatch is extracted from sources.track into one
shared path, so the heal honors the persisted capture policy, source
retention, and store wiring identically to an explicit track.

Verified live on titan: discovery flagged dev-tools (28,079 records,
no coverage), walked it, saved its first complete snapshot, and the
Mac's replica refetched automatically and lists the toolchain.
2026-09-16 01:14:12 -07:00
Jamie Pine 59b05a6c8d Persist the peer replica inventory so it survives the owner's absence
R3 slice of docs/plans/2026-09-15-source-runtime-reliability.md. The
replica inventory lived only in memory, rebuilt by a successful sync on
connect, so a restart with the owner offline lost every replica even
though the snapshot artifacts sat on disk, perfectly loadable.

Every sync now writes a manifest beside its artifacts carrying the
owner's identity and each source's info, generation, and sync time. A
source whose fetch failed keeps its previous entry while an older
artifact exists, so one failed transfer cannot erase a replica from
the known set; a source the owner's successful listing no longer
carries is dropped, since a full enumeration is removal evidence.

Startup restores the known inventory from disk without contacting any
owner: artifacts that load become live shares exactly as a sync would
have built them, and a source whose artifact is missing or unreadable
stays listed as replica_unavailable with the owner's last-synced
counts rather than vanishing. A racing reconnect sync always wins over
a restore.

Verified live against the real fleet: with titan's daemon stopped, the
Mac cold-restarted to all nine replicas and answered searches over
them from the disk-restored arenas; titan's return refreshed the same
nine without duplication.
2026-09-16 00:36:17 -07:00
Jamie Pine 0aa0d390cc Resolve a volume's root from its anchor, not its shortest source
R2 slice of docs/plans/2026-09-15-source-runtime-reliability.md.

volume_of derived an anchored volume's root from the shortest
registered source root on it, so a machine whose only source was the
home directory resolved the whole data volume's root to the home
directory. The tracked-volume fallback in locate answered the real
mount, and the two resolvers took turns: every restore found a
snapshot whose saved root disagreed with the resolved root and pushed
it aside for a fresh walk, forever.

An anchored source now recovers the mount point from its own root and
relative_root, which inverts exactly the join that produced it, so
every consumer of volume_of agrees with the tracked mount. The
shortest-root fallback survives only for unanchored sources, where it
is what lets a source inside a network share join the share's map.

Verified live across two daemon restarts: the snapshot restored in
place byte-for-byte with no move-aside, 103,898 entries served from
the restart cache with zero walks dispatched, and no new
mismatched-root artifact appeared.
2026-09-15 23:17:45 -07:00
Jamie Pine 9242a14a17 Synthesize missing ancestors so a child is never filed at the root
R1 slice of docs/plans/2026-09-15-source-runtime-reliability.md: parent
resolution becomes a prerequisite for committing a descendant.

Discovery hands a directory to another worker before sending that
directory's own observation, so a child could reach the writer a batch
ahead of its parent, resolve its parent lookup to None, and be
committed as a child of the source root. That is the shape behind the
live home store's parentless rows and their hashing errors: NULL is a
legal parent_uuid, so no constraint ever objected, and the ledger then
addressed the file by its bare title.

The writer now treats the child's path as filesystem evidence its
ancestors exist: missing ancestors are synthesized as directory
records in the same batch, shallowest first, so parent_uuid = NULL
returns to meaning exactly "child of the source root". The real
directory observation later converges on the synthesized record
through the path binding and repairs its timestamps. Existing
malformed rows repair through Moved rebinds as walks re-observe them,
which preserves their content identities; Changed is never used for
structural repair because it clears the content evidence.

store_for also gains a per-source open gate: concurrent first callers
previously each opened the pool, loaded the ledger, and spawned a
writer task before one won the map insertion. Openers now coalesce.
2026-09-15 23:06:18 -07:00
Jamie Pine 81accc3ad5 Build the search refinement bar with working filters
Phase 3 of docs/plans/2026-09-15-search-refinement.md. The Filters
button opens a panel whose every control maps onto a wire filter the
daemon answers: kind, extension, size presets, modified-date presets,
and a hidden-files toggle. Active filters badge the button, and the
toolbar shows the true result count from total_found.

Facets are now folded over each partition's full filtered match set
before narrowing, so a facet count describes the whole match set
rather than one page, and they gain a content-kind map keyed by the
wire spelling so the panel can echo a facet key straight back as a
content_types value. The search functions return one SearchPage
carrying results, total, and facets.

Filter state is stored in wire shape through the previously dead
searchFilters context state; the parallel camelCase SearchFilters
interface that nothing ever read is gone, and useExplorerFiles sends
the live filters instead of a hardcoded all-null literal. Verified
live: an uppercase extension filter matches lowercase files with the
true filtered total.
2026-09-15 21:46:51 -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 af34703731 Make search sort, pagination, and filters honest
Phase 1 of docs/plans/2026-09-15-search-refinement.md. Every parameter
search.files accepts is now either honored or fails closed; nothing is
validated, logged, and ignored anymore.

Ordering and pagination live in a new pipeline module with a
backend-neutral candidate type, per R6 of the source-runtime plan: one
deterministic global sort before final pagination, equal keys settling
on case-folded name and record id so results stop reshuffling between
keystrokes. Library search sums each partition's full filtered match
count into total_found and contributes only the page window of
candidates to the merge, so a partition can never lose a result that
belongs on the page. The 200-result cap and the synthetic pagination
block are gone; the response reports the caller's window over the true
total (a "spacedrive" search now reports 496 matches instead of a
complete-looking 200).

Filter fixes: extensions compare case-insensitively, hidden entries
are excluded by default and included with include_hidden (advertised
in available_filters), and a missing timestamp fails a date filter
instead of passing it, so the unanswerable AccessedAt and IndexedAt
fields fail closed rather than silently matching everything.

Deletes dead code the plan condemned: sorting.rs and the unused
facets.rs duplicates, plus the legacy entry-era imports in query.rs.
Verified live: --limit 5 returns five with the true total, size and
name sorts order and reverse, offset pages advance.
2026-09-15 21:20:08 -07:00
Jamie Pine fb0dab3e60 Stop the self-nudging hashing loop and give jobs real progress
The Mac daemon was dispatching a background content-identity job every
thirty seconds forever: 2,470 job rows, each surfacing on Home as a
"Processing... 0%" card with no source and no reason. Three causes,
three fixes.

The data-dir exclusion only knew one firmlink spelling. is_own_data
compared against a canonicalized path, but canonicalize does not
resolve firmlinks, so watcher events under /System/Volumes/Data sailed
past a /Users-spelled data dir. The daemon's own SQLite journals then
dirtied the home source, the nudged hashing job wrote the store, and
the loop fed itself. The exclusion now holds every spelling, and the
watcher event loop drops own-data events before they can dirty a root.

A dirty root with nothing pending no longer gets a job. The nudge asks
the store for its pending count first; one indexed query replaces a
dispatch, a job row, and a phantom card. A failed count still
dispatches so the job surfaces the store error.

Both hashing jobs now announce themselves before the first byte is
read ("{source} — 0 of N files") and advance progress within a claim
by hashing in sub-chunks, with failures counted toward completion so
the bar cannot stall under errors. Sub-chunk results are queued to the
store as they finish, so an interrupt loses at most one chunk of work.

Verified live: job count flat across three nudge intervals with active
writes under the home source, where it previously gained two per minute.
2026-09-15 21:01:25 -07:00
Jamie Pine 954c6d980d Make the source store flush a real durability barrier
R1 slice of docs/plans/2026-09-15-source-runtime-reliability.md: queue
drainage can no longer be reported as durable success.

commit() now returns how many writes failed to land, the writer tracks
failures since the last flush barrier, and flush() answers with them:
a dead writer or a failed commit is an error, never silence. Row-by-row
salvage stays; the rows it cannot save are now counted instead of
cleared into oblivion. Failed content-identity and unreadable-mark
writes count against the barrier too.

The pending-work queries (files_needing_content, files_needing_
verification, and their counts) return Result instead of turning a
database failure into an empty vector or zero, so the hashing and
verification jobs fail with a reason rather than reading a broken
store as a drained queue. Both jobs now propagate flush failures at
every barrier, which makes the landed rule that a job is complete only
after its final write batch commits actually enforceable. freeze_into
refuses to certify a store whose flush failed; counts() still answers
from committed rows but logs the unhealthy writer.
2026-09-15 20:35:13 -07:00
Jamie Pine bb033de1b2 Stop two destructive paths from consuming source evidence
First R-slice groundwork for docs/plans/2026-09-15-source-runtime-reliability.md,
pulled ahead of R1/R2 because both paths can destroy unreproducible data today.

Opening a source store whose records predate parent addressing dropped
the record, content, edge, and facet tables so a walk could rebuild
them. That assumed the origin is reproducible, which source durability
forbids: content rows carry integrity evidence that cannot be recomputed
once the drive is in a drawer. The store now refuses to open with a
typed UnsupportedGeneration error and stays intact until a
data-preserving migration converts it. Assertions already survived; now
the evidence the migration needs does too.

A restart snapshot whose saved root disagreed with the resolved root was
deleted before reindexing. The disagreement can mean a remount, but the
live Mac log shows it also fires when the root resolver changes its
answer, and a resolution bug must not destroy the only artifact. The
snapshot now moves aside to a .mismatched-root file, keeping the oldest
aside copy when the mismatch repeats.
2026-09-15 20:20:29 -07:00
Jamie Pine f175927f08 Keep fleet statistics through the ResourceChanged path
libraries.info summed cached peer summaries at read time, but every call
also kicks a background recalculation whose ResourceChanged event carried
the persisted local-only statistics. The client's normalized cache merges
events over responses, so the fleet totals were overwritten seconds after
every fetch and the overview always settled back on local capacity.

The summing now lives in one helper on the peer module, applied by the
read and by both statistics emitters before they publish, so events and
responses carry the same numbers. The persisted library config keeps
local figures only. The device-summary request also moved ahead of the
snapshot loop in sync_device: one failing source snapshot aborted the
function before the summary was ever fetched, and the device's totals
are still true when a snapshot is not.
2026-09-15 19:26:42 -07:00
Jamie Pine 4bc442e43b Make ephemeral search case-insensitive and stop indexing files twice
Name-registry keys are now Unicode-lowercased at insert, remove, and
snapshot restore, matching the query that search already lowercases;
arena nodes keep the original name for display. Matching drops the
exact/prefix/contains tiering: substring subsumes both, and scoring
already ranks exact hits first, so an exact name no longer hides
everything containing it.

The volume map no longer walks volumes the platform hides from users.
On macOS the sealed system volume at / was mapped as a second partition
and its walk descended /Users, duplicating the firmlinked data-volume
tree under the short spelling. Directory browse and path-scoped search
now route their paths through locate_path the way sources.track already
does, so every ephemeral-index writer and reader agrees on the volume's
spelling of a path.

Verified live: previously duplicated files return once, mixed-case
names match lowercase queries, and an exact-name query returns the
full result set with the exact hit ranked first.
2026-09-15 18:40:41 -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 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 cf5f3f45a3 Add paths.context and finish demoting locations
The path bar reads a typed path-context query backed by the daemon's
source, volume map, watcher, persistence, and pin state, replacing the
location/index-mode indicator. Containment resolves on the daemon with
path components after volume alias normalization; a pin no longer
implies indexing or watching, and the docs say so.
2026-09-15 17:09:17 -07:00
Jamie Pine aa589a767b Stop device presence flapping, recognize Tailscale paths
Every closed connection fired ConnectionLost for its device, but sync
and remote ops open a connection per request and drop it, so paired
devices cycled offline/online on every 30-second peer refresh. A loss
now prunes closed connections and marks the device disconnected only
when none remain — the durable job-activity stream keeps presence
honest. devices.list also treats the registry's Connected state as
connected when iroh's path watcher momentarily reads None.

Direct paths on Tailscale's CGNAT range (100.64/10, fd7a:115c:a1e0::)
classify as a new Tailscale connection method with its own badge,
instead of reading as generic internet. Verified live from titan: 20
consecutive samples online, alternating LocalNetwork/Tailscale as iroh
picks between the LAN and tailnet routes.
2026-09-15 16:27:13 -07:00
Jamie Pine 5f49a0141e Report the same fleet-wide statistics on every device
Three layers of wrong in the overview numbers, now fixed:

The daemon shelled out to bare zfs/zpool, which a non-login launch
(TrueNAS, systemd) does not have on PATH, so the pool-root capacity
correction never ran and a 68 TB pool reported 5.6 TB with 0 B used.
Binaries now resolve against /sbin and /usr/sbin. Statistics also
overlay live volume-manager capacity over the persisted rows, which
froze at track time, and a re-sighted volume refreshes its row's
capacity. The duplicated volume-capacity implementation now delegates
to one static body.

The Files Indexed card added source counts to library statistics that
were already derived from the same source rows, roughly doubling every
number. It reads the statistics alone now.

Each device answers a byterange DeviceSummary with its own statistics,
peers cache it on every sync, and libraries.info adds the cached
summaries at read time. Every device reports the same fleet totals,
computed once by the machine that owns the data.
2026-09-15 15:52:01 -07:00
Jamie Pine 50fdbad59d Search every source across paired devices
Library-wide search now fans out over peer replicas alongside the
local volume indexes, so one query answers for every device's sources
from memory. Path-scoped search on another device's path resolves the
covering replica the same way directory listings do.

Library-wide hits also carry the owning device's slug; they previously
went out with an empty slug, which made results unaddressable even
locally.
2026-09-15 05:46:19 -07:00
Jamie Pine 3ac1f0af78 Surface peer replicas in sources.list and serve remote listings
sources.list now appends paired devices' sources from the peer-mount
replica set, attributed with the owning device's id and label and
marked status "replica". The overview device card renders them, so a
paired NAS shows its sources instead of "No volumes".

directory_listing serves paths on another device straight from that
device's replicated index: same conversion as the local cached path
(extracted into shared helpers), no indexer dispatch, since indexing
happens on the owner. Verified from titan against the Mac's replica.

sync_device now holds a per-device lock; the refresh loop clears the
debounce before running, so a reconnect in that window was loading the
same snapshots twice in parallel.
2026-09-15 05:20:07 -07:00
Jamie Pine a0180d1b7e Deliver remote job completions, announce only user-requested walks
Remote job completion events never survived the wire: JobOutput is
adjacently tagged, and serde's tagged enums only deserialize from
self-describing maps, but the job activity plane serialized with
rmp_serde::to_vec (compact arrays). Every JobCompleted frame failed to
decode on the receiving device, leaving remote job caches full of
ghost rows stuck at running. Encode with to_vec_named.

Indexer walks now announce on the event bus only when someone asked
for them: volumes.index, indexing action, and sources track. Browses
and the launch map of each drive run quietly; their job rows and logs
are kept as before.
2026-09-15 03:17:05 -07:00
Jamie Pine 632d043590 Keep background hashing passes off the event bus
The watcher's hashing nudge dispatches a content_identity job per dirty
root every thirty seconds, and launch dispatches one per source. Those
jobs announced start and completion like user-initiated work, so a
drive with constant writes (CCTV) rang the web UI's job-done sound on
a timer. Background passes keep their job row for the record but no
longer emit events; the pass dispatched by sources track still
announces.
2026-09-15 02:59:07 -07:00