Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ EXPERIMENTAL, so on-disk formats and the CLI may change without notice.

## [Unreleased]

### Added

- **A change to a synced folder can be staged, reviewed, and published on
purpose.** `fabric sync stage <target>` makes a staged copy under the fabric
home, outside every synced folder, and records the published file's hash as
its base. `fabric sync staged` lists staged files with their state, and
`fabric sync publish` writes the reviewed bytes into the folder, refusing a
file whose published version moved since it was staged unless `--force`.
With the daemon running, a publish is one scan, one persist, and one
reconcile per peer for the whole set. No build that has shipped can publish a
staged file, because every daemon publishes only from the folder it walks
and its own include globs.

### Changed

- **The dormant `fabric/sync-ipc/1` local bridge now has a frozen contract.**
Expand Down
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1082,6 +1082,10 @@ fabric sync ls
fabric sync ls --json
fabric sync rm <name-or-folder>
fabric sync reload
fabric sync stage <target> [--from <file>] [--entry <name>]
fabric sync staged [--entry <name>] [--json]
fabric sync publish <target>... | --all --entry <name> [--force]
fabric sync discard <target>... [--entry <name>]
```

`fabric sync add` is a convenience writer for `syncs.toml`; the file can also be
Expand Down Expand Up @@ -1115,6 +1119,46 @@ restart or removing and later re-adding the name starts a new counter epoch.
`fabric sync ls --json` emits a stable array with all of those fields plus a
Boolean `drift` for automation.

### Stage a change before it publishes

In a synced folder the write is the publish. The moment bytes land on disk,
every peer receives them, so an edit made over several saves publishes each
save, and nothing can be reviewed before it crosses. Staging adds the missing
state: a change that exists, is complete, and has not been distributed.

```sh
fabric sync stage ~/catalog/docs/handbook.md # prints the path to edit
$EDITOR ~/.local/share/fabric/staging/st2-declarations-default/docs/handbook.md
fabric sync staged # review: state, hashes, both paths
fabric sync publish ~/catalog/docs/handbook.md # one revision, one reconcile per peer
```

`stage` resolves the entry from the target path and the include globs, seeds
the staged copy from the published file when there is one, or from `--from`,
and records the published file's hash as the base. `staged` lists every staged
file with its state: `new` when nothing is published at the target, `edit` when
the published file is still the one it was staged against, and `stale` when
that file moved. `publish` refuses a stale file unless `--force`, so a change a
peer made under a staged edit is never overwritten by accident. `discard`
removes staged copies and touches nothing in the folder.

A staged file lives under `<fabric home>/staging/<entry>/`, never inside a
synced folder. That location is the whole guarantee. A daemon decides what to
publish from exactly two things, the folder it walks and the include globs in
its own `syncs.toml`, so no build that has shipped can publish a staged file,
including an older binary after a rollback. A daemon restart with staged files
publishes nothing. `stage` refuses when the staging tree would lie inside a
synced folder.

With the daemon running, `publish` hands the reviewed bytes to it, and the
daemon writes them under the entry's operation guard: one scan, one persist,
and one reconcile carries the whole set to each peer. Without a daemon, or
with one older than this command, `publish` writes each file atomically into
the folder itself and says so; the next scan records them, and a set can then
reach a peer in more than one reconcile. A crash in the middle of a multi-file
publish leaves the written files to publish on the next start and the rest
still staged; publish again to finish.

### Sync an st2 catalog safely

An st2 catalog mixes declarative fleet data, durable bus data, and strictly
Expand Down
38 changes: 38 additions & 0 deletions src/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,44 @@ pub enum ControlRequest {
},
/// Report which process owns sync and whether its companion is present.
SyncRuntimeStatus,
/// Publish staged files into one sync entry under its operation guard, so
/// the set becomes one scan, one persist, and one reconcile on each peer.
///
/// Carries the bytes rather than a path: the daemon writes exactly what the
/// caller reviewed, and a staged file is small by the nature of the thing
/// being staged.
SyncPublish {
name: String,
files: Vec<SyncPublishFile>,
#[serde(default)]
force: bool,
},
Shutdown,
}

/// One file of a `SyncPublish` request.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncPublishFile {
/// The path inside the synced folder, in manifest form.
pub rel: String,
pub bytes: Vec<u8>,
#[serde(default)]
pub executable: bool,
/// The hex content hash of the published file when this was staged, or
/// `None` when there was no published file. The daemon refuses to publish
/// over a file that moved since, unless forced.
#[serde(default)]
pub base: Option<String>,
}

/// One file of a `SyncPublished` response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncPublishedFile {
pub rel: String,
pub version: u64,
pub hash: String,
}

fn default_persist() -> bool {
true
}
Expand Down Expand Up @@ -204,6 +239,9 @@ pub enum ControlResponse {
SyncRuntimeStatus {
runtime: SyncRuntimeStatus,
},
SyncPublished {
files: Vec<SyncPublishedFile>,
},
Error {
message: String,
},
Expand Down
35 changes: 35 additions & 0 deletions src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3480,6 +3480,41 @@ async fn process_control_request(
ControlRequest::SyncRuntimeStatus => ControlResponse::SyncRuntimeStatus {
runtime: state.sync_runtime_status().await,
},
ControlRequest::SyncPublish { name, files, force } => {
let Some(engine) = state.sync_engine() else {
anyhow::bail!("this daemon does not own sync, so it cannot publish");
};
let files = files
.into_iter()
.map(|file| {
let base = match file.base {
Some(hex) => Some(
crate::sync::manifest::ContentHash::from_hex(&hex).ok_or_else(|| {
anyhow::anyhow!("{}: the base is not a content hash", file.rel)
})?,
),
None => None,
};
Ok(crate::sync::staging::PublishFile {
rel: file.rel,
bytes: file.bytes,
executable: file.executable,
base,
})
})
.collect::<Result<Vec<_>>>()?;
let published = engine.publish_staged(&name, files, force).await?;
ControlResponse::SyncPublished {
files: published
.into_iter()
.map(|file| crate::control::SyncPublishedFile {
rel: file.rel,
version: file.version,
hash: file.hash.to_hex(),
})
.collect(),
}
}
ControlRequest::Shutdown => {
state.cancel.cancel();
ControlResponse::Ok
Expand Down
Loading
Loading