Skip to content

velos/Workspace

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

9 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Workspace

Workspace is a shell-agnostic Swift package for building agent and tool runtimes around a controlled filesystem model.

It gives you:

  • virtual filesystem abstractions
  • rooted and jailed disk access
  • in-memory filesystems
  • copy-on-write overlays
  • mounted multi-root workspaces
  • explicit permission checks for file operations
  • one Workspace actor for reads, tracked writes, tree summaries, snapshots, checkpoints, rollback, branching, and merge

Workspace is beta software and should be used at your own risk. It is useful for app and agent workflows, but it is not a hardened sandbox or a security boundary by itself.

Why

Many agent and tooling flows need more than plain disk I/O:

  • one isolated workspace per task
  • a shared scratch or memory area
  • the ability to read a real project without writing back to it
  • explicit approvals before reads or writes
  • tree summaries, JSON helpers, batched edits, and checkpoints without shell parsing

Workspace provides one model for those cases. You can back it with memory, a rooted directory on disk, an overlay snapshot, or a mounted combination of several filesystems.

What It Provides

  • Workspace: high-level actor API for file operations, mutation tracking, snapshots, checkpoints, rollback, branch, and merge
  • Checkpoint and CheckpointEvent: public checkpoint metadata and event stream values
  • Snapshot: durable capture/restore of a subtree
  • ChangeEvent: structured change notifications emitted by Workspace.watchChanges(at:recursive:)
  • FileSystem: low-level protocol for custom filesystem backends
  • ReadWriteFilesystem: real disk access rooted to a configured directory
  • InMemoryFilesystem: fully in-memory filesystem for isolated workspaces and tests
  • OverlayFilesystem: lazy copy-on-write view of a disk root — reads pass through, writes stay in memory
  • MountableFilesystem: compose multiple filesystems under one virtual tree
  • PermissionedFileSystem: wrap any filesystem with operation-level approvals
  • SandboxFilesystem: convenience wrapper for app sandbox roots
  • SecurityScopedFilesystem: security-scoped URL and bookmark-backed access
  • WorkspacePath: path normalization and joining helpers

Installation

Until this package is published to a remote, use it as a local SwiftPM dependency:

.dependencies: [
    .package(path: "../Workspace")
],
.targets: [
    .target(
        name: "YourTarget",
        dependencies: ["Workspace"]
    )
]

Workspace

Create an ephemeral workspace with the default initializer:

import Workspace

let workspace = Workspace()
try await workspace.writeFile("/notes/todo.txt", content: "ship it")

let text = try await workspace.readFile("/notes/todo.txt")
print(text) // ship it

Use a custom filesystem when the files should come from memory, disk, an overlay, a mount table, or a permission wrapper:

let filesystem = InMemoryFilesystem()
let workspace = Workspace(filesystem: filesystem)

After a workspace is created, workspace.filesystem exposes the backing filesystem as a normal FileSystem. The property itself is immutable, but the returned filesystem keeps its read/write capabilities:

let filesystem: any FileSystem = workspace.filesystem
let data = try await filesystem.readFile(path: "/notes/todo.txt")

Persist checkpoint artifacts with file-backed storage:

let root = URL(fileURLWithPath: "/tmp/workspace-checkpoints", isDirectory: true)
let workspace = Workspace(
    filesystem: InMemoryFilesystem(),
    storage: .directory(at: root)
)

Storage.directory(at:) writes checkpoint metadata, snapshot manifests, and a mutations.jsonl append log (one JSON record per line) under <url>/<workspaceId>/. Snapshots are content-addressed: file bytes are stored once per unique content in blobs/<sha256>, and each snapshot manifest references them by hash — so consecutive checkpoints share unchanged file content instead of re-serializing the whole tree, and contents are stored raw rather than base64-inflated. Legacy inline-snapshot JSON files and a legacy mutations.json array are still read (the latter is migrated to JSONL on first access). Blobs are retained until pruned by future GC tooling. The store assigns monotonic sequence numbers while holding mutations.lock (advisory flock where the OS supports it), so multiple Workspace instances that share a workspaceId and store do not collide on mutation sequence. Appends read only the log's final record to derive the next sequence, and a partial trailing line left by a crashed append is skipped on read and truncated before the next append. The current checkpoint head is derived from the parent-id graph (unparented tips), not only from createdAt ordering, which reduces surprises when wall clocks differ between processes. Listing mutations still reads the full log; very long histories may need application-level rotation. Coordinating multiple hosts or network disks that do not honor flock may still require extra synchronization.

Reads

let data = try await workspace.readData(from: "/blob.bin")
let text = try await workspace.readFile("/notes/todo.txt")
let exists = await workspace.exists("/notes/todo.txt")
let info = try await workspace.fileInfo(at: "/notes/todo.txt")
let entries = try await workspace.listDirectory(at: "/notes")
let matches = try await workspace.glob("/notes/*.txt", currentDirectory: "/")
let tree = try await workspace.walkTree("/")
let summary = try await workspace.summarizeTree("/")

Glob wildcards use shell semantics: * and ? match within a single path component, ** matches recursively across components, and character classes support negation ([!abc]).

Ranged reads avoid loading whole files when the backing filesystem supports seeking:

let slice = try await workspace.readData(from: "/blob.bin", offset: 1024, length: 4096)

At the FileSystem level, implementations also provide readFileChunks(path:chunkSize:) for streaming reads, createFile(path:data:) for exclusive creation (fails with EEXIST), and capabilities() to query optional features (symlinks, hard links, permissions, real-path resolution) without probe-and-catch.

JSON helpers encode and decode through Codable:

struct Config: Codable {
    var name: String
    var enabled: Bool
}

try await workspace.writeJSON(Config(name: "demo", enabled: true), to: "/config.json")
let config: Config = try await workspace.readJSON(from: "/config.json")
print(config.enabled) // true

Tracked Writes

Every public write records a mutation. The history is public — mutationRecords() returns the ordered records including per-file effects and text diffs — and the amount of detail is configurable per workspace:

let workspace = Workspace(
    filesystem: InMemoryFilesystem(),
    tracking: .fullDiffs(maxDiffBytes: 1_000_000) // cap diff computation to 1 MB files
)
// .full        — full diffs, no size cap (default)
// .pathsOnly   — touched paths and effects, no text diffs
// .disabled    — no history at all; change events still fire, but merge cannot
//                detect uncheckpointed edits made in this mode

Tracked writes:

try await workspace.writeFile("/notes/todo.txt", content: "one")
try await workspace.appendFile("/notes/todo.txt", content: " two")
try await workspace.writeData(Data([0xDE, 0xAD]), to: "/blob.bin")
try await workspace.createDirectory(at: "/docs")
try await workspace.copyItem(from: "/notes/todo.txt", to: "/docs/todo.txt")
try await workspace.moveItem(from: "/docs/todo.txt", to: "/docs/done.txt")
try await workspace.removeItem(at: "/docs/done.txt")

Batched edits and replacements can be previewed before execution:

let preview = try await workspace.previewEdits([
    .createDirectory(path: "/src"),
    .writeFile(path: "/src/a.txt", content: "one"),
])

let result = try await workspace.applyEdits([
    .appendFile(path: "/src/a.txt", content: " two"),
])

let replacement = try await workspace.applyReplacement(
    ReplacementRequest(pattern: "/src/*.txt", search: "one", replacement: "1")
)

print(preview.mode)       // preview
print(result.mode)        // execution
print(replacement.mode)   // execution

applyEdits and applyReplacement use logical rollback when failurePolicy is .rollback. Other policies may leave partial changes in place.

Watchers

let changes = await workspace.watchChanges(at: "/notes")

Task {
    for await change in changes {
        print(change.kind, change.path)
    }
}

try await workspace.writeFile("/notes/todo.txt", content: "ship it")

Checkpoint events are separate from file change events:

let checkpoints = try await workspace.watchCheckpointEvents()

Task {
    for await event in checkpoints {
        print(event.kind, event.checkpoint.label ?? "")
    }
}

Snapshots And Checkpoints

Snapshots capture filesystem contents. Checkpoints persist a snapshot plus lineage and summary metadata.

try await workspace.writeFile("/readme.txt", content: "v1")
let checkpoint = try await workspace.createCheckpoint(label: "before edits")

try await workspace.writeFile("/readme.txt", content: "v2")
let rollback = try await workspace.rollback(to: checkpoint.id, label: "restore v1")

let all = try await workspace.listCheckpoints()
let snapshot = try await workspace.snapshot(for: checkpoint)

print(rollback.rollbackSourceCheckpointId == checkpoint.id) // true
print(all.count)
print(snapshot.rootPath)

Public restoreSnapshot(_:) is also tracked as a workspace mutation. Checkpoint rollback records the tree changes it performs as a rollback mutation in addition to the rollback checkpoint, so the mutation log remains a complete account of filesystem changes. Long-running workspaces can bound the log with pruneMutationHistory(throughSequence:), which always retains the newest record so sequence numbers stay monotonic.

Branch And Merge

Branches are isolated Workspace actors cloned from the parent's current snapshot. They share the checkpoint store but not the filesystem, watchers, or mutation sequence. By default branch() materializes the snapshot into a new InMemoryFilesystem; pass filesystem: to use another implementation (for example a ReadWriteFilesystem if the branch should live on disk).

try await workspace.writeFile("/readme.txt", content: "base")
let base = try await workspace.createCheckpoint(label: "base")

let branch = try await workspace.branch(label: "agent draft")
try await branch.writeFile("/readme.txt", content: "draft")
let branchHead = try await branch.createCheckpoint(label: "draft ready")

let merged = try await workspace.merge(branch, label: "merge draft")

print(merged.parentCheckpointId == base.id)                 // true
print(merged.mergedFromWorkspaceId == branch.workspaceId)   // true
print(merged.mergedFromCheckpointId == branchHead.id)       // true

merge(_:) is optimistic. If the parent workspace head changed after branch() was created, merge throws WorkspaceError.mergeConflict(parentWorkspaceId:expectedBase:actualHead:). If the parent made tracked writes after branch() without creating a checkpoint, merge throws WorkspaceError.mergeUncheckpointedChanges(parentWorkspaceId:baseMutationCursor:currentMutationCursor:) instead of silently overwriting those edits; create a checkpoint or roll the parent back first.

mergeThreeWay(_:label:) merges even when both sides advanced. Each path is resolved against the branch's base checkpoint: the side that changed wins, identical changes merge cleanly, and when both sides changed the same path differently the merge applies nothing and returns structured conflicts instead of throwing:

let result = try await workspace.mergeThreeWay(branch, label: "merge draft")
if result.applied {
    print("merged as checkpoint \(result.checkpoint!.id)")
} else {
    for conflict in result.conflicts {
        print("conflict at \(conflict.path): \(conflict.kind)")
        // conflict.oursDiff / conflict.theirsDiff carry base→side diffs for text files
    }
}

Common Filesystem Patterns

Rooted Disk Workspace

let root = URL(fileURLWithPath: "/tmp/demo-workspace", isDirectory: true)
let filesystem = try ReadWriteFilesystem(rootDirectory: root)
let workspace = Workspace(filesystem: filesystem)

try await workspace.createDirectory(at: "/src", recursive: false)
try await workspace.writeFile("/src/main.swift", content: "print(\"hello\")\n")

Overlay On Top Of A Real Project

let projectRoot = URL(fileURLWithPath: "/path/to/project", isDirectory: true)
let filesystem = try await OverlayFilesystem(rootDirectory: projectRoot)
let workspace = Workspace(filesystem: filesystem)

let preview = try await workspace.summarizeTree("/Sources", maxDepth: 2)
try await workspace.writeFile("/SCRATCH.md", content: "overlay-only change\n")

The overlay is lazy: nothing is copied at configuration time, reads pass through to the source directory (so files that change on disk are visible immediately), and only mutated entries are copied up into memory. Deletions are recorded as whiteouts that hide the source entry without touching the disk. reload() discards all overlay writes and whiteouts.

Mounted Workspaces

let mounted = MountableFilesystem(
    base: InMemoryFilesystem(),
    mounts: [
        .init(mountPoint: "/workspace-a", filesystem: InMemoryFilesystem()),
        .init(mountPoint: "/workspace-b", filesystem: InMemoryFilesystem()),
        .init(mountPoint: "/memory", filesystem: InMemoryFilesystem()),
    ]
)

let workspace = Workspace(filesystem: mounted)
try await workspace.writeFile("/memory/plan.txt", content: "shared notes")
try await workspace.copyItem(from: "/memory/plan.txt", to: "/workspace-a/plan.txt")

Operation-Level Permissions

let filesystem = PermissionedFileSystem(
    base: InMemoryFilesystem(),
    authorizer: PermissionAuthorizer { request in
        switch request.operation {
        case .readFile, .listDirectory, .stat:
            return .allowForSession
        default:
            return .deny(message: "write access denied")
        }
    }
)

let workspace = Workspace(filesystem: filesystem)

Important Behavior

  • Reads do not load checkpoint state. Writes, checkpoint calls, rollback, branch, and merge do.
  • All checkpoint reads share the workspace actor barrier with file I/O, so they serialize behind in-flight writes.
  • writeJSON ends the file with a single trailing newline; readJSON decodes the value as usual. Checkpoint event polling (when using watchCheckpointEvents) uses Workspace.checkpointEventPollInterval (500 ms by default; shorten in tests to reduce wait time).
  • .inMemory storage still records one mutation per write in memory, including old-content capture for text diffs.
  • Branches created with .directory(at:) share the same storage directory as the parent but are partitioned by workspaceId.
  • walkTree and summarizeTree return stable path ordering, which is useful for deterministic tool output.

Limitations

  • Workspace is not a hardened sandbox.
  • Logical rollback is not crash-safe and does not coordinate with external processes.
  • OverlayFilesystem does not persist writes back to the original root.
  • Hard links across mounts are not supported.
  • Some filesystem types still use @unchecked Sendable; treat shared mutable class-based implementations carefully unless their synchronization guarantees are documented.

Security Notes

  • Jail and root enforcement belong to the underlying filesystem implementation.
  • ReadWriteFilesystem uses lstat semantics for entry-level operations (stat, exists, remove, move source, readSymlink): a symlink is handled as the link itself, never its target, so links pointing outside the root can be inspected and deleted but not read or written through.
  • Permission checks are additive. They do not replace path normalization or jail enforcement.
  • If you expose Workspace to model-driven or remote callers, the host still needs to define what roots, mounts, and permissions are acceptable.

Testing

swift test

About

A Swift library for building agent and tool runtimes around a controlled, composable virtual filesystem and high-level workspace API.

Resources

License

Stars

1 star

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages