Workspace is a Swift package for agent and tool runtimes that need controlled filesystem access, structured edits, revision history, and isolated transactions without shell parsing.
The package is beta software. Its rooted filesystems, authorization rules, and limits are useful safety layers, but they are not a hardened process sandbox.
.dependencies: [
.package(path: "../Workspace")
]The package contains one library product, Workspace, and has no external dependencies.
The public API centers on a small set of concepts:
Workspaceowns current mutable state, history, checkpoints, and events.Revisionaddresses either the current state or a checkpoint.Editdescribes a mutation.ChangeSetis the common result used by previews, edits, history, events, diffs, and transactions.Workspace.Transactionprovides isolated preview, three-way commit, and discard.FileSystemis the low-level protocol for custom storage backends.
An empty in-memory workspace needs no configuration:
import Workspace
let workspace = Workspace()
try await workspace.writeText("/notes/todo.txt", "ship it\n")
let text = try await workspace.readText("/notes/todo.txt")Use a rooted local filesystem and persisted revision store when state should survive process exit:
let files = try LocalFileSystem(root: projectURL)
let workspace = Workspace(
workspaceID: projectID,
fileSystem: files,
persistence: .directory(historyURL),
recording: .full(maxTextBytes: 1_000_000)
)The injected filesystem remains available as workspace.fileSystem so one Workspace can be
shared as the filesystem authority across shells, tool runtimes, and other adapters. Reads through
that reference are always fine. Mutations made directly through it bypass Workspace history,
checkpoints, and events, so integrations should either route mutations through the Workspace edit
pipeline or deliberately accept that those changes are unrecorded.
Every mutation returns the same structured ChangeSet:
let preview = try await workspace.preview([
.createDirectory("/Sources"),
.writeText("/Sources/main.swift", "print(\"hello\")\n")
])
let result = try await workspace.apply([
.createDirectory("/Sources"),
.writeText("/Sources/main.swift", "print(\"hello\")\n")
])
print(result.changes.touchedPaths)
print(result.changes.statistics.additions)apply is atomic by default. .stopOnError and .continueAfterError expose partial failures explicitly.
Direct conveniences—writeText, writeData, appendText, createDirectory, remove, copy, move, link creation, and permission changes—use the same edit pipeline.
Text replacement is an edit and shares its file selection and pattern engine with search:
let swiftFiles = FileSelection(
root: "/Sources",
include: ["**/*.swift"],
exclude: ["**/Generated/**"]
)
try await workspace.apply([
.replace(files: swiftFiles, pattern: .literal("OldName"), with: "NewName")
])let result = try await workspace.search(
SearchRequest(
pattern: .regularExpression(#"\bTODO\b"#),
files: swiftFiles,
contextLines: 2
)
)
for match in result.matches {
print("\(match.path):\(match.lineNumber): \(match.line)")
}Search is deterministic, line-oriented, does not follow symlinks, and reports binary, oversized, or truncated work as structured result data.
Checkpoints are persisted revisions; there is no second public snapshot representation.
let before = try await workspace.createCheckpoint(label: "before")
try await workspace.writeText("/README.md", "Updated\n")
let after = try await workspace.createCheckpoint(label: "after")
let oldText = try await workspace.readText(
"/README.md",
at: .checkpoint(before.id)
)
let changes = try await workspace.diff(
from: .checkpoint(before.id),
to: .checkpoint(after.id)
)Checkpoint reads use manifest metadata and load only selected blobs. TextDiff includes total line counts, addition/deletion statistics, hunks, and UTF-16 intra-line ranges.
Use Workspace.Archive only when a fully materialized, portable subtree is actually required:
let archive = try await workspace.archive(at: "/Sources")
try await anotherWorkspace.restore(archive, at: "/Imported")Inspect checkpoint-store usage and preview retention before deleting anything:
let usage = try await workspace.storageStatistics()
let preview = try await workspace.compact(
retaining: .latest(
50,
preservingLabeled: true,
preserving: [releaseCheckpoint.id]
),
dryRun: true
)
print(preview.removedCheckpointCount)
print(preview.reclaimedBytes)Apply the same policy by omitting dryRun:
let report = try await workspace.compact(retaining: .latest(50))Compaction rebases retained checkpoints to their nearest retained ancestor and recomputes their summaries. Retained rollback and transaction checkpoints also retain an existing checkpoint they reference as provenance. .all keeps every checkpoint while sweeping orphaned manifests and blobs.
Directory-backed stores serialize checkpoint commits, revision reads, and compaction with a persistent lifecycle lock. Mutation history is independent and is not pruned by checkpoint retention.
Directory-backed checkpoint capture also reuses existing CAS blobs when a file's size and available modification timestamp are unchanged. It still walks directory metadata for correctness, but only reads and hashes file contents that appear changed. Filesystems that do not provide modification timestamps safely fall back to reading those files.
Transactions replace separate branch and merge APIs:
let transaction = try await workspace.beginTransaction(label: "agent draft")
try await transaction.writeText("/README.md", "Draft\n")
let preview = try await transaction.preview()
let commit = try await transaction.commit(strategy: .threeWay)
if !commit.applied {
for conflict in commit.conflicts {
print(conflict.path, conflict.kind)
}
try await transaction.discard()
}Manual conflicted transactions remain active. The scoped convenience commits on success and discards on failure or conflict:
let result = try await workspace.transaction { transaction in
try await transaction.writeText("/result.txt", "done\n")
return "agent result"
}Transactions are logically atomic within cooperative workspace use. They are not crash-safe against an external process racing the commit.
let events = await workspace.events()
Task {
for await event in events {
print(event)
}
}
let mutations = try await workspace.history()Change events, returned edit results, and mutation history all carry the same ChangeSet representation. Checkpoint events share the same stream.
LocalFileSystem also pushes out-of-band filesystem invalidations through FSEvents on macOS and
inotify on Linux. events() and the path-focused watchChanges(at:recursive:) turn those
invalidations into structured ChangeSet values. External changes remain unrecorded in mutation
history, matching the direct-filesystem contract above. Directory-backed checkpoint stores use the
same push model for cross-instance checkpoint events; there is no periodic polling loop.
Built-in filesystems use consistent names and constructor-driven configuration:
InMemoryFileSystemLocalFileSystemOverlayFileSystemMountedFileSystemSandboxFileSystemSecurityScopedFileSystemAuthorizedFileSystemLimitedFileSystem
Compose wrappers explicitly:
let local = try LocalFileSystem(root: projectURL)
let limited = LimitedFileSystem(
base: local,
limits: FileSystemLimits(
maxTotalBytes: 50_000_000,
maxEntryCount: 10_000,
maxWriteBytes: 1_000_000
)
)
let rules = RuleBasedPermissionAuthorizer(
rules: [
PermissionRule(
operations: [.readFile, .listDirectory, .stat],
pathPrefix: "/Sources",
effect: .allow
)
]
)
let authorized = AuthorizedFileSystem(base: limited, authorizer: rules)
let workspace = Workspace(fileSystem: authorized)Authorization supports one-shot, duration-limited, and session approvals plus bounded audit records. Prefix rules use path-component boundaries and require both paths to match for copy and move.
WorkspaceToolAdapter exposes dependency-free function definitions for read, glob, search, apply,
diff, and checkpoint operations. Each definition contains an ordinary JSON Schema object, so an
integration can translate it into its model provider's tool format without adding a provider SDK to
Workspace:
let adapter = WorkspaceToolAdapter(workspace: workspace)
let definitions = WorkspaceToolAdapter.definitions
let resultJSON = try await adapter.call(
name: "workspace_diff",
arguments: Data(#"{"from_checkpoint_id":"...","unified_patch":true}"#.utf8)
)All mutating adapter calls use the Workspace edit/checkpoint APIs, so they retain the same history,
event, transaction, authorization, and limit behavior as direct callers. The diff result carries the
structured ChangeSet and can optionally include unified-patch text for prompt or CLI interop.
swift build
swift test --disable-xctest --enable-swift-testingCI runs Swift 6.2 tests on macOS and Linux.