Skip to content

Add file operation coordinator to the registry - #13628

Open
franknoirot wants to merge 2 commits into
codex/effect-filesystem-capabilitiesfrom
codex/effect-filesystem-operations
Open

Add file operation coordinator to the registry#13628
franknoirot wants to merge 2 commits into
codex/effect-filesystem-capabilitiesfrom
codex/effect-filesystem-operations

Conversation

@franknoirot

@franknoirot franknoirot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Stacked on #13597, towards #12350, closes #13183. This adds FileOperations as what will become the sole application-facing filesystem authority. It uses Effect pipelines to coordinate file system operations robustly.

What the FileOperations Effect pipeline does

  1. Adds coordinated access checks, stat, existence, directory and file reads, writes, copies, moves, renames, removals, and strict or unique file/directory creation.
  2. Accepts either string or Uint8Array contents for every file-writing operation, encoding strings as UTF-8 and snapshotting mutable bytes before they enter the queue.
  3. Uses shared/exclusive path locks so related paths serialize without blocking unrelated paths.
  4. Adds directory-membership locks so listings cannot observe a child being added, removed, or renamed halfway through an operation.
  5. Pipes through the structured errors from FileSystem underneath, and adds additional client error reporting around directory and file lock failures (see "Client error reporting" below).

The FileOperations system is also registered as a core extension called fileOperations in this PR. As we complete our "strangler fig" migration it will become the only place callers are allowed to access the file system, enforced by lint rules (the next PR #13630 introduces this).

Coordination guarantees

One of the core problems using Effect helps solve for us is path locking. This allows us to parallelize IO operations that are unrelated while strictly ordering ones that depend on each other: it's exactly what Effect pipelines are for. Here are some of the guarantees from the locks in this PR:

  1. Access checks, stat, existence checks, and file reads coordinate with mutations of the same path.
  2. Directory reads hold a shared membership lock until both the listing and entry stats complete; child creation, removal, and rename hold the corresponding exclusive membership lock.
  3. A parent-directory rename, move, or removal waits for coordinated reads and writes beneath that directory, and descendant operations wait while the parent mutation is in progress.
  4. move first attempts rename and falls back to copy-and-remove for cross-device moves while retaining the source/destination locks for the whole compound operation.
  5. Acknowledged writes to the same path are serialized, so a later completion cannot be overwritten by an older queued write.
  6. Writes to existing sibling files can still proceed concurrently. A write that creates a new path briefly participates in the parent membership lock.
  7. Strict creation fails on an existing path; unique creation selects a non-colliding numbered name within this runtime's coordination boundary.
  8. Multi-path operations acquire stable lock plans to avoid deadlocks.

These guarantees apply to callers using this mounted FileOperations runtime. They do not claim atomicity against another browser tab, process, or backend writer.

Client error reporting

A rejected FileOperations request emits one file_operations_error report; internal FileSystem work does not emit a second report. The report records the coordinated operation, underlying filesystem operation, typed error, and allowlisted backing error code/name.

Paths, messages, stacks, filenames, and contents are excluded for user privacy. The reporter also supplies a fixed file-operations route instead of inheriting the current application route, because file routes may contain encoded absolute paths. FileAlreadyExists from strict createFile or createDirectory is intentionally not reported because callers use that result for ordinary collision feedback.

fast-check is introduced in this PR for property testing / fuzzing

fast-check is specifically aimed at testing broad categories of failures by generating test inputs and expectations based on "properties". This is great for detecting and preventing race conditions, which have plagued our IO systems. They have a *.properties.* file name.

The property tests in this PR generate Unicode strings, operation streams, and path sets rather than enumerating only hand-picked examples. Each generated case is checked against observable invariants, and a failure is shrunk to a smaller reproducible counterexample. Conventional tests remain alongside them as readable examples.

@vercel

vercel Bot commented Sep 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
modeling-app Ready Ready Preview Sep 4, 2026 11:46pm UTC

Request Review

@franknoirot
franknoirot force-pushed the codex/effect-filesystem-operations branch from a5b665f to a56a2b6 Compare September 3, 2026 20:38
@franknoirot
franknoirot force-pushed the codex/effect-filesystem-operations branch from a5b665f to a56a2b6 Compare September 3, 2026 20:41
@franknoirot
franknoirot force-pushed the codex/effect-filesystem-operations branch from a56a2b6 to 7f2804b Compare September 3, 2026 21:01
@franknoirot
franknoirot force-pushed the codex/effect-filesystem-operations branch from 7f2804b to 8e507b3 Compare September 3, 2026 21:17
@franknoirot
franknoirot force-pushed the codex/effect-filesystem-operations branch from 8e507b3 to df639a3 Compare September 4, 2026 13:00
@franknoirot
franknoirot force-pushed the codex/effect-filesystem-operations branch from df639a3 to fe93250 Compare September 4, 2026 13:29
@franknoirot
franknoirot force-pushed the codex/effect-filesystem-operations branch from fe93250 to 4e798cc Compare September 4, 2026 13:53
@franknoirot
franknoirot force-pushed the codex/effect-filesystem-operations branch from 4e798cc to 34f6fca Compare September 4, 2026 14:08
@franknoirot
franknoirot force-pushed the codex/effect-filesystem-operations branch 3 times, most recently from cf488ce to b18ae25 Compare September 4, 2026 14:32
@franknoirot
franknoirot force-pushed the codex/effect-filesystem-operations branch from b18ae25 to 4b812e0 Compare September 4, 2026 15:01

@franknoirot franknoirot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay I know that adding both fast-check and Effect in this stack is a lot but I hope I can convince y'all it's worth it here.

}

/** Build the preferred filename or its numbered collision variant. */
export function fileNameCandidate(name: FileNameParts, suffix: number): string {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this is duplicative of our path helpers, but idk I kinda want this to stand alone? Or I need our path libraries to have less baggage and use only pure functions

} from '@src/lib/fileSystem/fileOperations'

/** Promise facade for coordinated project-directory and file operations. */
export interface FileOperationsRegistryService {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where to start: this service should have everything we need to interact with a file operation queue in one shared place, so let me know if anything looks wrong.

Comment on lines +54 to +64
const runRuntimePromise = async <A, E, R extends FileOperations>(
program: Effect.Effect<A, E, R>,
onFailure?: (error: E) => void
): Promise<A> => {
const result = await runtime.runPromise(program.pipe(Effect.either))
if (Either.isLeft(result)) {
onFailure?.(result.left)
return Promise.reject(result.left)
}
return result.right
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Effect calls nested operations "programs" and they can compose really nicely in reusable ways, since it's just a bougie functional programming library. Effect.either is a reusable program they export that picks left (unsuccessful) or right (successful) outcomes, like lambda calculus.

Each of the service methods is just wrapped in this so they all handle rejection and piping in the same way.

program: Effect.Effect<A, FileSystemError, FileOperations>
) =>
runRuntimePromise(program, (error) =>
reportFileOperationsError(operation, error)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where the unsuccessful case's return type is established, so Effect can tell the TypeScript type checker what error types are possible for each of these service methods!

Comment on lines +32 to +40
while (true) {
const parent = backing.dirname(current)
if (parent === current) {
break
}

if (!modesByPath.has(parent)) {
modesByPath.set(parent, 'shared')
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where we detect descendants for lock status

Comment on lines +153 to +208
const reserveLock = (locks: typeof pathLocks, path: string) =>
SynchronizedRef.modifyEffect(locks, (entries) => {
const existing = entries.get(path)
if (existing) {
const next = new Map(entries)
next.set(path, { ...existing, users: existing.users + 1 })
return Effect.succeed([existing.semaphore, next] as const)
}

return Effect.makeSemaphore(EXCLUSIVE_LOCK_PERMITS).pipe(
Effect.map((semaphore) => {
const next = new Map(entries)
next.set(path, { semaphore, users: 1 })
return [semaphore, next] as const
})
)
})

const releaseLock = (locks: typeof pathLocks, path: string) =>
SynchronizedRef.update(locks, (entries) => {
const existing = entries.get(path)
if (!existing) {
return entries
}

const next = new Map(entries)
if (existing.users === 1) {
next.delete(path)
} else {
next.set(path, { ...existing, users: existing.users - 1 })
}
return next
})

const withLocks = <A, E, R>(
locks: typeof pathLocks,
requirements: readonly PathLockRequirement[],
operation: Effect.Effect<A, E, R>,
index = 0
): Effect.Effect<A, E, R> => {
const requirement = requirements[index]
if (!requirement) {
return operation
}

return Effect.acquireUseRelease(
reserveLock(locks, requirement.path),
(semaphore) =>
semaphore.withPermits(
requirement.mode === 'exclusive'
? EXCLUSIVE_LOCK_PERMITS
: SHARED_LOCK_PERMITS
)(withLocks(locks, requirements, operation, index + 1)),
() => releaseLock(locks, requirement.path)
)
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This right here is why I reached for Effect. Locking and coordination is hard as fuck, but this library lets us compose those policies that, at least at the end of the pipeline, are pretty readable, and way easier to debug. Plus it has built-in notions of parallelization and semaphores.

Comment on lines +249 to +259
const coordinateMutation = <A, E, R>(
paths: readonly string[],
operation: Effect.Effect<A, E, R>,
membershipPaths = paths.map((path) => backing.dirname(path))
) =>
trackMutation(
withPathLocks(
pathLockRequirements(backing, paths),
withDirectoryMembershipLocks(membershipPaths, 'exclusive', operation)
)
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See now we can see here that mutations are coordinated by applying exclusive membership locks to all the necessary paths.

Comment on lines +367 to +420
return FileOperations.of({
pending: SubscriptionRef.get(pending),
pendingChanges: pending.changes,
stat: (path) => coordinateRead(path, fileSystem.stat(path)),
canReadWrite: (path) =>
coordinateRead(path, fileSystem.canReadWrite(path)),
exists: (path) => coordinateRead(path, fileSystem.exists(path)),
readDirectory: (path) =>
coordinateDirectoryRead(path, fileSystem.readDirectory(path)),
readFile: (path) => coordinateRead(path, fileSystem.readFile(path)),
copy: (source, destination, options) =>
coordinateMutation(
[source, destination],
fileSystem.copy(source, destination, options?.overwrite)
),
move: (source, destination) =>
coordinateMutation(
[source, destination],
moveEntry(source, destination)
),
writeFile: (path, contents) =>
coordinateWrite(path, snapshotFileContents(contents)),
createFile: (path, contents) =>
coordinateMutation(
[path],
createFileAt(path, snapshotFileContents(contents))
),
// As with unique directories, an exclusive parent lock makes candidate
// selection and creation one coordinated operation.
createUniqueFile: (parent, name, contents) =>
coordinateMutation(
[parent],
createUniqueFileAt(parent, name, snapshotFileContents(contents)),
[parent]
),
createDirectory: (path) =>
coordinateMutation([path], createDirectoryAt(path)),
// Lock the parent exclusively while selecting and creating the name.
// Child mutations take a shared parent lock, so no coordinated caller
// can claim the same candidate between the existence check and creation.
createUniqueDirectory: (parent, preferredName) =>
coordinateMutation(
[parent],
createUniqueDirectoryAt(parent, preferredName),
[parent]
),
remove: (path) => coordinateMutation([path], fileSystem.remove(path)),
rename: (source, destination) =>
coordinateMutation(
[source, destination],
fileSystem.rename(source, destination)
),
})
})

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the part I hope is most readable.

Comment on lines +54 to +58
/**
* Model the guarantees owned by one coordinator runtime. A backing adapter
* must separately guarantee that an individual successful write is atomic;
* cross-runtime ordering requires a shared platform or server authority.
*/

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This means that we can only prove that this shit works on our current browser tab or desktop app, not that coordination occurs across clients. Although cool thing: since this uses an logical clock for versions, once a STUN server owns that we could have live collab.

})

it(
'preserves write order and never reads an older acknowledged revision',

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the big important data loss property test.

@franknoirot
franknoirot force-pushed the codex/effect-filesystem-operations branch from 9d5a48d to a56da93 Compare September 4, 2026 23:37
@franknoirot franknoirot changed the title Coordinate filesystem operations through Effect Add file operation coordinator to the registry Sep 4, 2026
@franknoirot
franknoirot marked this pull request as ready for review September 5, 2026 00:59
@franknoirot
franknoirot requested review from a team as code owners September 5, 2026 00:59
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.

Add property-based editor testing with fast-check

1 participant