Skip to content

Stage a change in a synced folder and publish it on purpose - #189

Merged
myobie merged 3 commits into
mainfrom
feat/sync-staging
Sep 6, 2026
Merged

Stage a change in a synced folder and publish it on purpose#189
myobie merged 3 commits into
mainfrom
feat/sync-staging

Conversation

@myobie

@myobie myobie commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

A change to a synced folder can now be staged, reviewed, and published on purpose.

The gap

In a synced folder the write is the publish. The moment bytes land on disk, every peer receives them. An edit made over several saves publishes each save, a half-written file is a published file, and nothing can be reviewed before it crosses. Nothing in the config or the CLI expresses "prepared, not yet published".

The mechanism

A staged file lives at <fabric home>/staging/<entry>/<rel>, never inside a synced folder. Four verbs:

  • fabric sync stage <target> [--from <file>] [--entry <name>] resolves the entry from the target path and the include globs, seeds the staged copy from the published file or from --from, prints the path to edit, and records the published file's hash as the base. It refuses a target no include glob matches and refuses when the staging tree would lie inside a synced folder.
  • fabric sync staged [--entry <name>] [--json] lists staged files as new, edit, or stale, with both paths for a reviewer to diff.
  • fabric sync publish <target>... | --all --entry <name> [--force] hands the reviewed bytes to the daemon. The daemon publishes under the entry's operation guard: it checks every base against the live manifest before writing anything, writes each file atomically through the engine write path with a receipt in the write journal, scans once, persists once, and wakes the entry loop. A set is one manifest change and one reconcile on each peer, the watcher does not rescan the daemon's own writes, and a refused set changes nothing. Without a daemon, or with one older than this request, the CLI writes each file atomically into the folder itself and says so.
  • fabric sync discard <target>... removes staged copies and touches nothing in the folder.

fabric sync ls now shows staged=N per entry, so a staged change is not forgotten.

Why a tree outside the folder, and not a prefix, an exclude, or a hold

A daemon decides what to publish from exactly two things: the folder it walks and the include globs in its own syncs.toml. The entry config ignores unknown fields, so a new config key is dropped silently by an older build, and no engine state or control request reaches an older build's scan. A prefix inside the folder, an exclude a tool flips, or a per-path hold in engine state would all publish the staged bytes the moment a machine rolled back to an older binary. A tree outside every folder is never walked by any build that has shipped. That is the only guarantee that comes from code already on every machine, and it is why the fleet can keep rolling one machine at a time.

The cost is that a staged file is edited at the staged path, which stage prints and seeds.

What a peer sees during staging: nothing

  • Staged bytes sit under <home>/staging, and stage refuses when that tree lies inside any configured folder.
  • The scan walks only the entry folder (scan_disk_snapshot_with_limit), and the watcher watches only that folder (spawn_watcher).
  • A manifest entry comes only from a scan or from a peer.
  • The wire carries only manifest entries and content for the hashes they name.

Proof

Seven library tests over two loopback engines, all written before the implementation and failing on it, all passing now:

  • a staged file never enters the manifest or reaches a peer;
  • the same file placed inside the folder DOES reach the peer, kept as a should_panic control that must fail forever, so the absence assertion is known to see a leak;
  • an engine restart with a staged file publishes nothing;
  • a publish of three files costs one scan and one persist, wakes the entry loop, and the peer adopts all three in one reconcile;
  • a publish after the published file moved is refused by name and changes nothing, and --force publishes the next version;
  • the watcher acknowledges a publish write from its receipt without a second scan;
  • stage refuses a target outside the include, a target outside every folder, and a staging tree inside a folder.

One real-daemon test over iroh: a staged file does not reach the peer while two control files written into the folder cross in the same windows, it stays unpublished across a restart of the staging daemon, and it reaches the peer with the reviewed bytes after SyncPublish.

Full library suite on the final head: 545 passed, 5 ignored, 09:13:08Z to 09:13:45Z.

Old binary, measured: the installed 0.2.5+4dc0cac daemon ran in a temporary home with a bus entry on a temporary folder. A file under that home's staging tree stayed at present=0 and appeared in no state file after 4 passes and 8 scans. The same bytes written inside the folder reached present=1 and the state log at once. Window 09:10:08Z to 09:10:18Z. Both temporary directories were removed.

CLI smoke on a temporary daemon built from this branch: stage, staged, staged=1 in sync ls, the include refusal, publish at version 1, a stale refusal printing both hashes, --force to version 3, the local fallback with the daemon down, and discard. Window 09:14:47Z to 09:14:51Z.

Limits, stated

A multi-file publish is atomic per file, not across a crash. A crash after some writes and before the scan leaves those files to publish on the next start and the rest still staged; publish again to finish. Publishing over a path a peer edited concurrently follows newer-wins, as any two writes do; the base check refuses the accidental case and --force chooses the deliberate one. A reviewer on another machine cannot see a staged file, by design.

In a synced folder the write is the publish. There is no state in
which a change exists, is complete, and has not yet been distributed,
so nothing can be reviewed before it crosses.

This adds the tests for that state and the declarations they need.
A staged file lives under the fabric home, outside every synced
folder, and publishes only on request. The tests prove a staged file
never enters a manifest or reaches a peer, survives a restart
unpublished, publishes as one scan and one reconcile, refuses to
publish over a file that moved since it was staged, and is
acknowledged by the watcher without a second scan. A leak control
places the same file inside the folder and must fail, so the absence
assertion is known to see a leak. The daemon-level test proves the
same over real connections.

Every staging function and the engine's publish entry point still
return "not implemented", so all of these fail. The leak control
passes, as it must.
A staged file lives under the fabric home, outside every synced
folder. A daemon publishes only from the folder it walks and the
include globs in its own config, so no build that has shipped can
publish a staged file, including an older one after a rollback.

fabric sync stage <target> resolves the entry by folder and include,
seeds the staged copy from the published file or from --from, prints
the path to edit, and records the published file's hash as the base.
fabric sync staged lists staged files as new, edit, or stale. fabric
sync publish hands the reviewed bytes to the daemon, or writes them
atomically into the folder itself when no daemon can take them, and
refuses a stale file unless --force. fabric sync discard removes
staged copies. fabric sync ls now counts staged files per entry.

The daemon side of publish is the next commit; until then the engine
still answers "not implemented" and the publish tests stay red.
The daemon now takes a publish request and performs it under the
entry's operation guard: it checks every base against the live
manifest before writing anything, writes each file atomically through
the engine write path with a receipt in the write journal, scans once,
persists once, and wakes the entry loop. The set is therefore one
manifest change and one reconcile on each peer, the watcher
acknowledges the daemon's own writes without a second scan, and a
refused set changes nothing.

Atomic per file, not across a crash: a crash after some writes leaves
those files to publish on the next start and the rest still staged.

All seven staging tests pass, including the leak control that must
fail. The daemon-level test passes over real connections.
@myobie
myobie merged commit 8b4c7c6 into main Sep 6, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant