Add proposal: flit watch, one-way folder sync
Design doc for a new sending-side mode: watch a local directory, push new/changed files to one already-known peer automatically, no manual `flit send` per file. Deliberately scoped as one-way (source -> destination, push only) rather than two-way sync, to avoid drifting into conflict-resolution/delete-propagation territory that belongs to actual sync tools, not flit. Grounded in the real code: confirms Session.SendFile is synchronous/ blocking (its return value alone is a sufficient completion signal, no new ack plumbing needed), and that daemon.go's runPeerLoop already has the exact persistent-connection/backoff shape this needs — just aimed at receiving, not sending. Notably, the receiving side needs zero new code: an existing `flit daemon` configured with the sender as a trusted peer already does the right thing on receipt. One open question flagged as needing resolution before implementation (not deferred): whether multiple watch entries targeting the same peer would collide on the same deterministic pairwise room name. Not implemented — design stage. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
219
PROPOSAL-watch.md
Normal file
219
PROPOSAL-watch.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# Proposal: `flit watch` — one-way folder sync over flit
|
||||
|
||||
**Status:** proposed, not built. Self-contained — written so a fresh agent
|
||||
with no prior conversation context can pick this up and implement it
|
||||
without anything explained first.
|
||||
|
||||
## Context (read this first)
|
||||
|
||||
Full background on flit is in [README.md](./README.md) and
|
||||
[PROTOCOL.md](./PROTOCOL.md) — read both before touching code. The
|
||||
relevant existing pieces for this proposal, all in `cli/`:
|
||||
|
||||
- `cmd/flit/main.go` — `send(ctx, path)`: creates a `transport.Session`,
|
||||
generates a one-shot room + QR invite, and on `OnConnected` calls
|
||||
`sess.SendFile(path)`. **`SendFile` is synchronous and blocking** — it
|
||||
returns only once the transfer is fully complete (or errors). Its
|
||||
return value alone is a sufficient "did this actually get there"
|
||||
signal; no separate completion callback exists or is needed.
|
||||
- `cmd/flit/daemon.go` — the pattern this proposal reuses almost
|
||||
wholesale, just aimed the other direction:
|
||||
- `daemonConfig`/`peerConfig` — TOML config (`~/.flit/daemon.toml`),
|
||||
a list of trusted peers by `id`/`label`.
|
||||
- `runPeerLoop(ctx, tc, myID, peer, downloadDir)` — maintains a
|
||||
**persistent connection to one named peer**, reconnecting with
|
||||
exponential backoff (2s → 30s cap) on disconnect. Sets
|
||||
`OnFileOffer` to auto-accept anything the peer sends and save it to
|
||||
`downloadDir`.
|
||||
- This is receive-only today. **`flit watch` needs the send-side
|
||||
sibling of this exact loop** — same persistent-connection/backoff
|
||||
shape, but triggered by local filesystem events instead of incoming
|
||||
offers.
|
||||
- `internal/transport/transport.go` — `Session.Join(ctx, roomName,
|
||||
trustedPeerID)`, `transport.PairRoomName(a, b)` (deterministic
|
||||
pairwise room from two peer IDs — this is what makes "known device,
|
||||
no fresh QR" work at all), `Session.SendFile(path)`,
|
||||
`Session.PeerID()`.
|
||||
|
||||
## Motivation
|
||||
|
||||
At one point the idea on the table was two-way directory sync — watch
|
||||
a folder, keep it mirrored to a known host, in both directions. That
|
||||
was deliberately walked back to **one-way sync**: source → destination
|
||||
only, push only, never reconciling anything back. That sidesteps the
|
||||
entire class of problem full bidirectional sync brings with it
|
||||
(conflict resolution, delete propagation, catch-up ordering). The
|
||||
moment this proposal starts growing bidirectional-anything, that's a
|
||||
sign it's drifting toward re-implementing a general sync tool — flit's
|
||||
own case study already draws that line explicitly ("Syncthing syncs
|
||||
continuously; too heavy for ad hoc one-offs" — flit is deliberately
|
||||
*not* trying to be that). This proposal should stay on the "one
|
||||
watched folder pushes new files to one named peer, and that's the
|
||||
whole feature" side of that line.
|
||||
|
||||
The actual want: drop a file into a watched folder, it gets encrypted
|
||||
and pushed to a specific already-known device automatically — no
|
||||
manual `flit send`, no QR, no re-running anything by hand per file.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. The receiving side needs no changes
|
||||
|
||||
Worth stating plainly since it simplifies scope: an already-running
|
||||
`flit daemon`, configured with the sender as a trusted peer, already
|
||||
does exactly the right thing on receipt — auto-accept, save to
|
||||
`download_dir`. This proposal is entirely about the sending side.
|
||||
|
||||
### 2. `flit watch` — a new subcommand, new config file
|
||||
|
||||
`~/.flit/watch.toml`, same shape as `daemon.toml` deliberately, for
|
||||
familiarity:
|
||||
|
||||
```toml
|
||||
signal_url = "wss://your-anchor.example.com/ws"
|
||||
turn_url = ""
|
||||
turn_secret = ""
|
||||
|
||||
[[watch]]
|
||||
dir = "/home/user/backups/documents"
|
||||
peer_id = "<hex Ed25519 pubkey of the destination device>"
|
||||
peer_label = "home-server"
|
||||
pattern = "*.pdf,*.docx" # optional glob include-list; default "*" if omitted
|
||||
stable_after = "10s" # optional quiet period before considering a file ready
|
||||
```
|
||||
|
||||
Multiple `[[watch]]` blocks are allowed — different folders can go to
|
||||
different peers, or the same peer, independently.
|
||||
|
||||
### 3. Local state = the send queue, no separate queue structure needed
|
||||
|
||||
One state file per watch entry:
|
||||
`~/.flit/watch-state/<sha256(dir + peer_id)>.json` —
|
||||
`{ "<relative-path>": { "size": N, "mtime": N, "sha256": "...", "sent_at": N|null } }`.
|
||||
|
||||
**The invariant that makes this simple**: `sent_at` is only ever set
|
||||
*after* `SendFile` returns without error. A file with `sent_at: null`,
|
||||
or whose current size/mtime/hash no longer matches what's recorded, is
|
||||
"needs sending" — that's the entire queue. No separate retry logic:
|
||||
if `SendFile` fails partway (peer disconnects mid-transfer), the state
|
||||
was never advanced, so the file is exactly as eligible for retry on
|
||||
the next connection as it was before the attempt.
|
||||
|
||||
### 4. Startup: reconcile state against reality, don't trust only live events
|
||||
|
||||
On start, before subscribing to filesystem events, walk `dir`
|
||||
(respecting `pattern`) and diff against the state file. This catches
|
||||
anything that changed while the watcher wasn't running — a live-only
|
||||
`fsnotify` subscription would silently miss files dropped in during
|
||||
downtime.
|
||||
|
||||
### 5. The watch-and-send loop — `runPeerLoop`'s sibling
|
||||
|
||||
```go
|
||||
// runWatchLoop mirrors daemon.go's runPeerLoop: a persistent connection
|
||||
// to one named peer, reconnecting with the same backoff. Differs only in
|
||||
// what drives sends — a local file-ready queue instead of OnFileOffer.
|
||||
func runWatchLoop(ctx context.Context, tc transport.Config, myID string, w watchConfig, state *watchState) {
|
||||
room := transport.PairRoomName(myID, w.PeerID)
|
||||
backoff := 2 * time.Second
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
sess, err := transport.NewSession(dataDir(), tc)
|
||||
// ... same connect/reconnect shape as runPeerLoop ...
|
||||
sess.OnConnected = func(verified bool) {
|
||||
backoff = 2 * time.Second
|
||||
drainQueue(sess, w, state) // send everything currently queued, in order
|
||||
}
|
||||
// fsnotify events (and the startup reconciliation) push into the
|
||||
// same queue; if already connected when a new file becomes ready,
|
||||
// send it immediately rather than waiting for the next OnConnected.
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`drainQueue` sends queued files one at a time via `sess.SendFile(path)`
|
||||
— sequential, not concurrent, since flit's session model is one peer
|
||||
connection at a time and concurrent sends would need to be queued
|
||||
DataChannel-side anyway (see "Multi-file send" in the flit case study
|
||||
for the existing precedent of handling several files across one
|
||||
session, just not built for a filesystem-driven trigger).
|
||||
|
||||
### 6. Debounce — don't send a file mid-write
|
||||
|
||||
`stable_after` is a quiet-period debounce: after the *last* fsnotify
|
||||
write event for a given file, wait that long before considering it
|
||||
ready. Without this, a large file being actively written would get
|
||||
offered (and likely fail hash verification on the receiving end, or
|
||||
worse, silently transfer a truncated version) the moment its first
|
||||
bytes land. This is a real requirement, not a nice-to-have — flag it
|
||||
as such if a fresh implementation is tempted to skip it for a first
|
||||
cut.
|
||||
|
||||
### 7. Never touches deletes
|
||||
|
||||
The watcher only reacts to create/write events. A file deleted from
|
||||
the source directory is never deleted from the destination, and never
|
||||
should be — that's the entire point of one-way sync rather than a
|
||||
mirror. Don't wire up `fsnotify`'s remove/rename events to anything
|
||||
that deletes on the far end.
|
||||
|
||||
## Security considerations
|
||||
|
||||
- **This sends files with no per-file human confirmation** — a
|
||||
deliberate, meaningful departure from interactive `flit send`
|
||||
(always a conscious action) and even from `daemon`'s auto-accept
|
||||
(still gated behind that peer being explicitly marked trusted with
|
||||
`autoAccept` in the keyring). The safety knob here is entirely
|
||||
`watch.toml` itself: which directories are named, and each entry's
|
||||
`pattern`. Recommend a **`flit watch --dry-run`** mode that walks the
|
||||
configured directories and prints what *would* be sent, without
|
||||
connecting to anything — a way to sanity-check a new watch config
|
||||
before it goes live and silently starts pushing files.
|
||||
- **`pattern` should default to something narrower than `*` in
|
||||
practice**, even though `*` is the technical default if omitted —
|
||||
worth calling out in whatever docs ship with this, since an
|
||||
unconstrained watch on a broad directory (e.g. a whole home folder)
|
||||
turns "sync my documents" into "sync everything that ever lands in
|
||||
this tree," which is a much bigger promise than most `[[watch]]`
|
||||
entries are meant to make.
|
||||
- **The destination is exactly as trusted as any other flit peer** —
|
||||
this doesn't introduce a new trust primitive, it reuses the existing
|
||||
Ed25519 identity + pairwise-room model as-is. A `peer_id` here is
|
||||
the same kind of already-known, already-paired device as anything in
|
||||
the PWA's "Known devices" list or `daemon.toml`.
|
||||
- **No encryption-at-rest concern beyond what already exists** — files
|
||||
are E2E encrypted in transit exactly like every other flit transfer;
|
||||
this proposal doesn't change flit's crypto at all, only adds a new
|
||||
trigger for when `SendFile` gets called.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Large initial backlogs** — if `dir` already has thousands of files
|
||||
on first run, the startup reconciliation queues all of them at once.
|
||||
Sequential sending (per §5) means this could take a long time before
|
||||
catching up to "live." Acceptable for a first cut; a
|
||||
`--max-startup-queue` or similar throttle could be added later if
|
||||
this turns out to matter in practice — not designing it ahead of an
|
||||
actual backlog being a problem.
|
||||
- **Multiple watchers targeting the same peer simultaneously** — each
|
||||
`[[watch]]` entry in §5's design opens its own `runWatchLoop`, meaning
|
||||
its own separate connection/room to the same peer if two entries
|
||||
share a `peer_id`. `transport.PairRoomName` is deterministic per
|
||||
peer pair, so two simultaneous sessions to the same peer would
|
||||
collide on the same room name — this needs either a single shared
|
||||
connection per unique `peer_id` (multiple watch entries feeding one
|
||||
`runWatchLoop`/queue) or confirmation that flit's transport already
|
||||
tolerates multiple simultaneous sessions to the same room gracefully.
|
||||
**Needs resolving before implementation, not deferred** — this is a
|
||||
correctness question about the connection model, not a nice-to-have.
|
||||
- **State file corruption/loss** — if `~/.flit/watch-state/*.json` is
|
||||
lost (disk issue, manual deletion), the next startup reconciliation
|
||||
will treat every file as unsent and re-push the entire directory.
|
||||
Harmless for correctness (the destination just re-receives what it
|
||||
already has) but potentially slow/wasteful for a large directory.
|
||||
Worth a passing mention in user-facing docs, not worth engineering
|
||||
around for a first version.
|
||||
Reference in New Issue
Block a user