Versions, Snapshots, and Transactions
Choose file history, explicit subtree snapshots, or staged multi-file writes.
Workspaces keep a version history for local files, materialize explicit subtree snapshots, and support staged multi-file transactions.
Choose The Recovery Boundary
| Need | Use |
|---|---|
| Revert one file to an earlier version | undo() |
| Publish several new file changes as one coherent update | transaction() |
| Preserve and later restore an existing file or subtree | ws.snapshot |
undo() is file history. A transaction protects a change you are about to make.
A snapshot is an explicit, reusable checkpoint of state that already exists.
Versioning And History
Every content change (write, edit, append, undo) appends an immutable
version. History is always recorded; there is no flag to enable.
await ws.write("/outputs/report.md", "draft one");
await ws.write("/outputs/report.md", "draft two");
const history = await ws.history("/outputs/report.md");
// [{ version: 2, operation: "write", ... }, { version: 1, ... }]
const old = await ws.read("/outputs/report.md", { version: 1 }); // "draft one"
const diff = await ws.diff("/outputs/report.md", { from: 1, to: 2 });
diff.unified; // git-style unified diff string
diff.hunks; // structured add/remove/context lines
await ws.undo("/outputs/report.md"); // restores v1 as a new v3diff defaults to the most recent change and applies to text files; binary
files throw. undo reverts the last content change without rewriting history.
rename, move, and copy start fresh history at the destination path.
delete purges a file's history.
Versioning is on by default with unlimited retention. Bound it per file with
versioning.maxVersions, which garbage collects the oldest snapshots and their
assets:
const ws = workspace({
id: "research",
namespace,
storage: storage({ records, assets }),
versioning: { maxVersions: 20 },
});Quota (limits.maxNamespaceBytes) counts live files only, not historical
snapshots. Use maxVersions to bound history storage.
Pinned Artifact Versions
finalize() pins the current version as the published artifact. Editing the
file afterwards creates new draft versions, but artifacts() and the workspace
manifest keep surfacing the pinned revision until you finalize again.
await ws.write("/outputs/report.md", "published copy", { kind: "report" });
const published = await ws.finalize("/outputs/report.md");
await ws.edit("/outputs/report.md", { find: "published", replace: "wip" });
await ws.read("/outputs/report.md"); // working copy
await ws.read("/outputs/report.md", { version: published.version }); // published copyUse this when the user needs a stable final output while the agent continues iterating.
Snapshots
Capture a local file or subtree through the singular snapshot facet. path is
required; namespace is optional when the Workspace already resolves one:
const checkpoint = await ws.snapshot.create({
path: "/outputs",
namespace: "thread:123",
});Restore is an exact-tree replacement. It creates or replaces captured files and deletes every later live file at or below the captured path that is absent from the snapshot. Files outside the captured tree are untouched.
The returned reference is JSON-safe and reusable. Persist the complete value,
not only its opaque id:
const stored = JSON.stringify(checkpoint);
const loaded = JSON.parse(stored) as typeof checkpoint;
const result = await ws.snapshot.restore(loaded);
// { restoredFiles, deletedFiles, unchangedFiles }Restore appends fresh file versions with operation "restore"; it never rewinds
history. It preserves artifact metadata plus both the working content and a
distinct published version when one was pinned at capture time. The snapshot
remains listable and reusable after restore.
List committed snapshots newest first. A path filter matches the exact normalized
capture path, not descendants. limit defaults to 50 and accepts integers from 1
through 100; pass the returned opaque cursor to continue the same Workspace,
namespace, and path-filtered listing:
let page = await ws.snapshot.list({ path: "/outputs", limit: 20 });
if (page.cursor) {
page = await ws.snapshot.list({
path: "/outputs",
limit: 20,
cursor: page.cursor,
});
}Snapshots own materialized payload copies and live until explicitly deleted:
await ws.snapshot.delete(checkpoint);Capture and restore reject any tree that intersects a source-backed mount; copy provider-owned files into a local mount first. Within one process, mutations for the same Workspace namespace are serialized and observed restore failures roll back. Generic stores do not provide process-crash, cross-process, or distributed atomicity, and snapshots are not a backup system.
Snapshots are explicit in this release. Automatic Effects checkpoints and
conflict policy belong to the later #258 integration
and are not performed by workspace.snapshot.
In Devtools, the Project Index Catalog lists where application definitions author these snapshot operations. Workspace activity and Run detail show operations that actually executed, with snapshot-specific aggregate summaries. Runtime snapshot instances remain runtime data and do not appear as static Catalog definitions.
See the snapshot API reference and storage costs and lifetime.
Transactions
Use transaction() when one deliverable spans several files and partial output
would be misleading. The callback writes to a staged workspace view first. If
the callback throws, the live namespace is unchanged. When it returns, Crux
commits the touched paths and returns the callback result.
const artifact = await ws.transaction(async (tx) => {
await tx.write("/outputs/report.md", "# Report", { status: "draft" });
await tx.write("/outputs/data.csv", "name,value\nalpha,1\n");
return tx.finalize("/outputs/report.md", { kind: "report" });
});Reads inside the callback see staged changes. Ordinary ws reads still see the
live namespace until commit:
await ws.write("/workspace/notes.md", "draft");
await ws.transaction(async (tx) => {
await tx.edit("/workspace/notes.md", {
find: "draft",
replace: "final",
});
await tx.read("/workspace/notes.md"); // "final"
await ws.read("/workspace/notes.md"); // "draft"
});
await ws.read("/workspace/notes.md"); // "final"The transaction surface is file-focused: list, read, write, edit,
delete, exists, stat, append, rename, move, copy, grep,
artifacts, and finalize. It intentionally does not include prompt adapters
such as asContext(), asTools(), or inject().
Transactions are namespace-local. Pass { namespace } to target a specific
namespace outside prompt resolution:
await ws.transaction(
async (tx) => {
await tx.write("/outputs/report.md", "# Report");
},
{ namespace: "thread:123" },
);Source-backed mount mutations are rejected before provider hooks run. Copy a
provider-backed file into /workspace or /outputs first if you need it inside
a transaction.
Transactions are implemented over the generic RecordStore contract, so the
same API works with in-memory storage, Convex record storage, Upstash Redis, and
custom conforming stores. The generic implementation rolls back touched live
paths when it observes a commit failure. Crash-proof multi-key durability still
depends on the underlying store or runtime; transaction() is not a distributed
transaction across stores.