refactor(core): simplify filesystem mutation protocol (#31059)
This commit is contained in:
parent
54f4974546
commit
ceccde7e84
13 changed files with 356 additions and 623 deletions
|
|
@ -16,9 +16,9 @@ function provide(directory: string, filesystem = FSUtil.defaultLayer) {
|
|||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
)
|
||||
const planning = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
|
||||
const commits = FileMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(planning))
|
||||
return Effect.provide(Layer.mergeAll(planning, commits))
|
||||
const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
|
||||
const mutation = FileMutation.layer.pipe(Layer.provide(filesystem))
|
||||
return Effect.provide(Layer.mergeAll(resolution, mutation))
|
||||
}
|
||||
|
||||
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
|
||||
|
|
@ -34,11 +34,11 @@ describe("FileMutation", () => {
|
|||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "hello.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "before"))
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
|
||||
|
||||
expect(yield* (yield* FileMutation.Service).write({ plan, content: "after" })).toEqual({
|
||||
expect(yield* (yield* FileMutation.Service).write({ target, content: "after" })).toEqual({
|
||||
operation: "write",
|
||||
target: plan.target.canonical,
|
||||
target: target.canonical,
|
||||
resource: "hello.txt",
|
||||
existed: true,
|
||||
})
|
||||
|
|
@ -50,12 +50,14 @@ describe("FileMutation", () => {
|
|||
it.live("writes a prospective internal file and creates parent directories", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "nested", "hello.txt") })
|
||||
const result = yield* (yield* FileMutation.Service).write({ plan, content: "hello" })
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({
|
||||
path: path.join("src", "nested", "hello.txt"),
|
||||
})
|
||||
const result = yield* (yield* FileMutation.Service).write({ target, content: "hello" })
|
||||
|
||||
expect(result).toEqual({
|
||||
operation: "write",
|
||||
target: plan.target.canonical,
|
||||
target: target.canonical,
|
||||
resource: "src/nested/hello.txt",
|
||||
existed: false,
|
||||
})
|
||||
|
|
@ -73,43 +75,62 @@ describe("FileMutation", () => {
|
|||
const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
|
||||
const files = yield* FileMutation.Service
|
||||
|
||||
yield* files.writeTextPreservingBom({ plan: preserved, content: "\uFEFFafter" })
|
||||
yield* files.writeTextPreservingBom({ plan: created, content: "\uFEFF\uFEFF\uFEFFcreated" })
|
||||
yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" })
|
||||
yield* files.writeTextPreservingBom({ target: created, content: "\uFEFF\uFEFF\uFEFFcreated" })
|
||||
|
||||
expect(yield* Effect.promise(() => fs.readFile(preservedPath, "utf8"))).toBe("\uFEFFafter")
|
||||
expect(yield* Effect.promise(() => fs.readFile(created.target.canonical, "utf8"))).toBe("\uFEFFcreated")
|
||||
expect(yield* Effect.promise(() => fs.readFile(created.canonical, "utf8"))).toBe("\uFEFFcreated")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects create when a prospective target appears after planning", () =>
|
||||
it.live("rejects create when a prospective target appears after resolution", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "appeared.txt")
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: "appeared.txt" })
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "appeared.txt" })
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "winner"))
|
||||
|
||||
expect(
|
||||
yield* (yield* FileMutation.Service).create({ plan, content: "replacement" }).pipe(Effect.flip),
|
||||
yield* (yield* FileMutation.Service).create({ target, content: "replacement" }).pipe(Effect.flip),
|
||||
).toMatchObject({
|
||||
_tag: "LocationMutation.RevalidationError",
|
||||
_tag: "FileMutation.TargetExistsError",
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("winner")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("creates when an existing target disappears after resolution", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "removed.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "before"))
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "removed.txt" })
|
||||
yield* Effect.promise(() => fs.rm(targetPath))
|
||||
|
||||
expect(yield* (yield* FileMutation.Service).create({ target, content: "after" })).toEqual({
|
||||
operation: "write",
|
||||
target: target.canonical,
|
||||
resource: "removed.txt",
|
||||
existed: false,
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("after")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("removes an existing internal file", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "remove.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "remove"))
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: "remove.txt" })
|
||||
const result = yield* (yield* FileMutation.Service).remove({ plan })
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "remove.txt" })
|
||||
const result = yield* (yield* FileMutation.Service).remove({ target })
|
||||
|
||||
expect(result).toEqual({
|
||||
operation: "remove",
|
||||
target: plan.target.canonical,
|
||||
target: target.canonical,
|
||||
resource: "remove.txt",
|
||||
existed: true,
|
||||
})
|
||||
|
|
@ -125,18 +146,18 @@ describe("FileMutation", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("writes an explicitly planned external target", () =>
|
||||
it.live("writes an explicitly resolved external target", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "external.txt")
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const result = yield* (yield* FileMutation.Service).write({ plan, content: "external" })
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const result = yield* (yield* FileMutation.Service).write({ target, content: "external" })
|
||||
|
||||
expect(result).toEqual({
|
||||
operation: "write",
|
||||
target: plan.target.canonical,
|
||||
resource: plan.target.resource,
|
||||
target: target.canonical,
|
||||
resource: target.resource,
|
||||
existed: false,
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("external")
|
||||
|
|
@ -145,19 +166,19 @@ describe("FileMutation", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("removes an explicitly planned external target", () =>
|
||||
it.live("removes an explicitly resolved external target", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "external.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "external"))
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const result = yield* (yield* FileMutation.Service).remove({ plan })
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const result = yield* (yield* FileMutation.Service).remove({ target })
|
||||
|
||||
expect(result).toEqual({
|
||||
operation: "remove",
|
||||
target: plan.target.canonical,
|
||||
resource: plan.target.resource,
|
||||
target: target.canonical,
|
||||
resource: target.resource,
|
||||
existed: true,
|
||||
})
|
||||
expect(
|
||||
|
|
@ -173,34 +194,18 @@ describe("FileMutation", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("propagates revalidation rejection after an ancestor swap", () =>
|
||||
it.live("reports a missing target as not removed without checking existence first", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
const parent = path.join(directory, "parent")
|
||||
yield* Effect.promise(() => fs.mkdir(parent))
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: path.join("parent", "new.txt") })
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.rmdir(parent)
|
||||
await fs.symlink(outside, parent)
|
||||
})
|
||||
Effect.gen(function* () {
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "missing.txt" })
|
||||
|
||||
expect(
|
||||
yield* (yield* FileMutation.Service).write({ plan, content: "escape" }).pipe(Effect.flip),
|
||||
).toMatchObject({
|
||||
_tag: "LocationMutation.RevalidationError",
|
||||
})
|
||||
expect(
|
||||
yield* Effect.promise(() =>
|
||||
fs.stat(path.join(outside, "new.txt")).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
),
|
||||
).toBe(false)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
expect(yield* (yield* FileMutation.Service).remove({ target })).toEqual({
|
||||
operation: "remove",
|
||||
target: target.canonical,
|
||||
resource: "missing.txt",
|
||||
existed: false,
|
||||
})
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -231,9 +236,9 @@ describe("FileMutation", () => {
|
|||
const files = yield* FileMutation.Service
|
||||
const firstPlan = yield* mutation.resolve({ path: "shared.txt" })
|
||||
const secondPlan = yield* mutation.resolve({ path: "shared.txt" })
|
||||
const first = yield* files.write({ plan: firstPlan, content: "first" }).pipe(Effect.forkChild)
|
||||
const first = yield* files.write({ target: firstPlan, content: "first" }).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
const second = yield* files.write({ plan: secondPlan, content: "second" }).pipe(Effect.forkChild)
|
||||
const second = yield* files.write({ target: secondPlan, content: "second" }).pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Deferred.isDone(secondStarted)).toBe(false)
|
||||
|
||||
|
|
@ -269,12 +274,12 @@ describe("FileMutation", () => {
|
|||
yield* Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const plan = yield* mutation.resolve({ path: "shared.txt" })
|
||||
const target = yield* mutation.resolve({ path: "shared.txt" })
|
||||
const expected = new TextEncoder().encode("initial")
|
||||
const first = yield* files.writeIfUnchanged({ plan, expected, content: "first" }).pipe(Effect.forkChild)
|
||||
const first = yield* files.writeIfUnchanged({ target, expected, content: "first" }).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
const second = yield* files
|
||||
.writeIfUnchanged({ plan, expected, content: "second" })
|
||||
.writeIfUnchanged({ target, expected, content: "second" })
|
||||
.pipe(Effect.flip, Effect.forkChild)
|
||||
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
|
|
@ -292,13 +297,13 @@ describe("FileMutation", () => {
|
|||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "stale.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "current"))
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: "stale.txt" })
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "stale.txt" })
|
||||
|
||||
expect(
|
||||
yield* (yield* FileMutation.Service)
|
||||
.writeIfUnchanged({ plan, expected: new TextEncoder().encode("older"), content: "replacement" })
|
||||
.writeIfUnchanged({ target, expected: new TextEncoder().encode("older"), content: "replacement" })
|
||||
.pipe(Effect.flip),
|
||||
).toMatchObject({ _tag: "FileMutation.StaleContentError", path: plan.target.canonical })
|
||||
).toMatchObject({ _tag: "FileMutation.StaleContentError", path: target.canonical })
|
||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("current")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
|
|
@ -326,9 +331,9 @@ describe("FileMutation", () => {
|
|||
const files = yield* FileMutation.Service
|
||||
const firstPlan = yield* mutation.resolve({ path: "first.txt" })
|
||||
const secondPlan = yield* mutation.resolve({ path: "second.txt" })
|
||||
const first = yield* files.write({ plan: firstPlan, content: "first" }).pipe(Effect.forkChild)
|
||||
const first = yield* files.write({ target: firstPlan, content: "first" }).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
const second = yield* files.write({ plan: secondPlan, content: "second" }).pipe(Effect.forkChild)
|
||||
const second = yield* files.write({ target: secondPlan, content: "second" }).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(secondFinished)
|
||||
expect(yield* Effect.promise(() => fs.readFile(secondPath, "utf8"))).toBe("second")
|
||||
|
||||
|
|
@ -341,9 +346,7 @@ describe("FileMutation", () => {
|
|||
)
|
||||
})
|
||||
|
||||
function instrumentWrites(
|
||||
run: (write: Effect.Effect<void, FSUtil.Error>, target: string) => Effect.Effect<void, FSUtil.Error>,
|
||||
) {
|
||||
function instrumentWrites(run: <E>(write: Effect.Effect<void, E>, target: string) => Effect.Effect<void, E>) {
|
||||
return Layer.effect(
|
||||
FSUtil.Service,
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -351,6 +354,9 @@ function instrumentWrites(
|
|||
return FSUtil.Service.of({
|
||||
...filesystem,
|
||||
writeWithDirs: (target, content, mode) => run(filesystem.writeWithDirs(target, content, mode), target),
|
||||
writeFile: (target, content, options) => run(filesystem.writeFile(target, content, options), target),
|
||||
writeFileString: (target, content, options) =>
|
||||
run(filesystem.writeFileString(target, content, options), target),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(FSUtil.defaultLayer))
|
||||
|
|
|
|||
|
|
@ -36,17 +36,13 @@ describe("LocationMutation", () => {
|
|||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "hello.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "hello"))
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
|
||||
|
||||
expect(plan.target).toMatchObject({
|
||||
expect(target).toMatchObject({
|
||||
canonical: yield* Effect.promise(() => fs.realpath(targetPath)),
|
||||
exists: true,
|
||||
resource: "hello.txt",
|
||||
})
|
||||
expect(plan.target.externalDirectory).toBeUndefined()
|
||||
expect(yield* (yield* LocationMutation.Service).revalidate(plan)).toMatchObject({
|
||||
canonical: plan.target.canonical,
|
||||
})
|
||||
expect(target.externalDirectory).toBeUndefined()
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
|
@ -55,18 +51,13 @@ describe("LocationMutation", () => {
|
|||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
|
||||
const root = yield* Effect.promise(() => fs.realpath(directory))
|
||||
|
||||
expect(plan.target).toMatchObject({
|
||||
expect(target).toMatchObject({
|
||||
canonical: path.join(root, "src", "new.txt"),
|
||||
exists: false,
|
||||
resource: "src/new.txt",
|
||||
})
|
||||
expect(plan.authority.canonical).toBe(path.join(root, "src"))
|
||||
expect(yield* (yield* LocationMutation.Service).revalidate(plan)).toMatchObject({
|
||||
canonical: plan.target.canonical,
|
||||
})
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
|
@ -98,16 +89,33 @@ describe("LocationMutation", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.live("follows an in-location symlink using ordinary filesystem semantics", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(directory, "actual"))
|
||||
await fs.symlink(path.join(directory, "actual"), path.join(directory, "linked"))
|
||||
})
|
||||
|
||||
expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
|
||||
canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "actual", "new.txt"),
|
||||
resource: "actual/new.txt",
|
||||
})
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("accepts an explicit absolute in-location target without external approval", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "new.txt")
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
expect(plan.target).toMatchObject({
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
expect(target).toMatchObject({
|
||||
canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "new.txt"),
|
||||
resource: "new.txt",
|
||||
})
|
||||
expect(plan.target.externalDirectory).toBeUndefined()
|
||||
expect(target.externalDirectory).toBeUndefined()
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
|
@ -117,13 +125,13 @@ describe("LocationMutation", () => {
|
|||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "new.txt")
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const root = yield* Effect.promise(() => fs.realpath(outside))
|
||||
expect(plan.target).toMatchObject({
|
||||
expect(target).toMatchObject({
|
||||
canonical: path.join(root, "new.txt"),
|
||||
resource: path.join(root, "new.txt").replaceAll("\\", "/"),
|
||||
})
|
||||
expect(plan.target.externalDirectory).toMatchObject({
|
||||
expect(target.externalDirectory).toMatchObject({
|
||||
directory: root,
|
||||
resource: path.join(root, "*").replaceAll("\\", "/"),
|
||||
})
|
||||
|
|
@ -138,11 +146,10 @@ describe("LocationMutation", () => {
|
|||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "existing.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const root = yield* Effect.promise(() => fs.realpath(outside))
|
||||
expect(plan.target).toMatchObject({ canonical: path.join(root, "existing.txt"), exists: true })
|
||||
expect(plan.authority.canonical).toBe(path.join(root, "existing.txt"))
|
||||
expect(plan.target.externalDirectory?.directory).toBe(root)
|
||||
expect(target).toMatchObject({ canonical: path.join(root, "existing.txt") })
|
||||
expect(target.externalDirectory?.directory).toBe(root)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
),
|
||||
|
|
@ -153,10 +160,9 @@ describe("LocationMutation", () => {
|
|||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "new", "nested", "file.txt")
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const root = yield* Effect.promise(() => fs.realpath(outside))
|
||||
expect(plan.authority.canonical).toBe(root)
|
||||
expect(plan.target.externalDirectory).toMatchObject({
|
||||
expect(target.externalDirectory).toMatchObject({
|
||||
directory: root,
|
||||
resource: path.join(root, "*").replaceAll("\\", "/"),
|
||||
})
|
||||
|
|
@ -165,66 +171,6 @@ describe("LocationMutation", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("rejects a symlink-ancestor swap during post-approval revalidation", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
const parent = path.join(directory, "parent")
|
||||
yield* Effect.promise(() => fs.mkdir(parent))
|
||||
const service = yield* LocationMutation.Service
|
||||
const plan = yield* service.resolve({ path: path.join("parent", "new.txt") })
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.rmdir(parent)
|
||||
await fs.symlink(outside, parent)
|
||||
})
|
||||
|
||||
const error = yield* Effect.flip(service.revalidate(plan))
|
||||
expect(error).toMatchObject({ _tag: "LocationMutation.RevalidationError" })
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects an existing target identity swap during post-approval revalidation", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "existing.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "first"))
|
||||
const service = yield* LocationMutation.Service
|
||||
const plan = yield* service.resolve({ path: "existing.txt" })
|
||||
yield* Effect.promise(async () => {
|
||||
const replacementPath = path.join(directory, "replacement.txt")
|
||||
await fs.writeFile(replacementPath, "second")
|
||||
await fs.rm(targetPath)
|
||||
await fs.rename(replacementPath, targetPath)
|
||||
})
|
||||
|
||||
const error = yield* Effect.flip(service.revalidate(plan))
|
||||
expect(error).toMatchObject({
|
||||
_tag: "LocationMutation.RevalidationError",
|
||||
reason: "mutation authority changed",
|
||||
})
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects a nearer prospective ancestor introduced after approval", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* LocationMutation.Service
|
||||
const plan = yield* service.resolve({ path: path.join("new", "nested", "file.txt") })
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "new")))
|
||||
|
||||
const error = yield* Effect.flip(service.revalidate(plan))
|
||||
expect(error).toMatchObject({
|
||||
_tag: "LocationMutation.RevalidationError",
|
||||
reason: "mutation authority changed",
|
||||
})
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
test("keeps project references outside the mutation input API", () => {
|
||||
expect(Object.keys(LocationMutation.ResolveInput.fields)).toEqual(["path", "kind"])
|
||||
expect(Schema.decodeUnknownSync(LocationMutation.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ let editApproved = false
|
|||
let blockRemoveTarget: string | undefined
|
||||
let removeStarted: Deferred.Deferred<void> | undefined
|
||||
let releaseRemove: Deferred.Deferred<void> | undefined
|
||||
let afterEditApproval = (): Effect.Effect<void> => Effect.void
|
||||
|
||||
const permission = Layer.succeed(
|
||||
PermissionV2.Service,
|
||||
|
|
@ -33,6 +34,7 @@ const permission = Layer.succeed(
|
|||
assertions.push(input)
|
||||
if (input.action === "edit") editApproved = true
|
||||
}).pipe(
|
||||
Effect.andThen(input.action === "edit" ? Effect.suspend(afterEditApproval) : Effect.void),
|
||||
Effect.andThen(
|
||||
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
|
||||
),
|
||||
|
|
@ -54,6 +56,7 @@ const reset = () => {
|
|||
blockRemoveTarget = undefined
|
||||
removeStarted = undefined
|
||||
releaseRemove = undefined
|
||||
afterEditApproval = () => Effect.void
|
||||
}
|
||||
|
||||
const filesystem = Layer.effect(
|
||||
|
|
@ -84,18 +87,18 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
|
|||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
)
|
||||
const planning = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
|
||||
const commits = FileMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(planning))
|
||||
const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
|
||||
const mutation = FileMutation.layer.pipe(Layer.provide(filesystem))
|
||||
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
|
||||
const patch = ApplyPatchTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(planning),
|
||||
Layer.provide(commits),
|
||||
Layer.provide(resolution),
|
||||
Layer.provide(mutation),
|
||||
Layer.provide(filesystem),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
return yield* body(yield* ToolRegistry.Service)
|
||||
}).pipe(Effect.provide(Layer.mergeAll(registry, planning, commits, patch)))
|
||||
}).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, patch)))
|
||||
}
|
||||
|
||||
const call = (patchText: string, id = "call-apply-patch") => ({
|
||||
|
|
@ -302,6 +305,26 @@ describe("ApplyPatchTool", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("rejects an add target that appears during permission approval", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const target = path.join(tmp.path, "appeared.txt")
|
||||
afterEditApproval = () => Effect.promise(() => fs.writeFile(target, "winner\n")).pipe(Effect.orDie)
|
||||
return withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* registry.execute(call("*** Begin Patch\n*** Add File: appeared.txt\n+replacement\n*** End Patch")),
|
||||
).toEqual({ type: "error", value: "Unable to apply patch at appeared.txt" })
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("winner\n")
|
||||
}),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reports earlier sequential applications when a later commit fails", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ let result: AppProcess.RunResult = {
|
|||
stderrTruncated: false,
|
||||
}
|
||||
let runFailure: AppProcess.AppProcessError | undefined
|
||||
let afterPermission = (_input: PermissionV2.AssertInput): Effect.Effect<void> => Effect.void
|
||||
let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect<ToolOutputStore.TruncateResult> =>
|
||||
Effect.succeed({ content: input.content, truncated: false })
|
||||
|
||||
|
|
@ -46,6 +47,7 @@ const permission = Layer.succeed(
|
|||
PermissionV2.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(Effect.suspend(() => afterPermission(input))),
|
||||
Effect.andThen(
|
||||
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
|
||||
),
|
||||
|
|
@ -91,6 +93,7 @@ const reset = () => {
|
|||
truncations.length = 0
|
||||
denyAction = undefined
|
||||
runFailure = undefined
|
||||
afterPermission = () => Effect.void
|
||||
result = {
|
||||
command: "mock",
|
||||
exitCode: 0,
|
||||
|
|
@ -118,6 +121,7 @@ const withTool = <A, E, R>(
|
|||
Layer.provide(registry),
|
||||
Layer.provide(permission),
|
||||
Layer.provide(mutation),
|
||||
Layer.provide(filesystem),
|
||||
Layer.provide(processLayer),
|
||||
Layer.provide(resources),
|
||||
Layer.provide(config),
|
||||
|
|
@ -187,6 +191,33 @@ describe("BashTool", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("rejects a workdir that stops being a directory during approval", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const workdir = path.join(tmp.path, "src")
|
||||
afterPermission = (input) =>
|
||||
input.action === "bash"
|
||||
? Effect.promise(async () => {
|
||||
await fs.rm(workdir, { recursive: true })
|
||||
await fs.writeFile(workdir, "not a directory")
|
||||
}).pipe(Effect.orDie)
|
||||
: Effect.void
|
||||
return Effect.promise(() => fs.mkdir(workdir)).pipe(
|
||||
Effect.andThen(withTool(tmp.path, (registry) => registry.execute(call({ command: "pwd", workdir: "src" })))),
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
expect(runs).toEqual([])
|
||||
expect(assertions.map((input) => input.action)).toEqual(["bash"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
it.live("executes a real shell command through AppProcess", () =>
|
||||
Effect.acquireUseRelease(
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ const assertions: PermissionV2.AssertInput[] = []
|
|||
const writes: string[] = []
|
||||
let reads = 0
|
||||
let denyAction: string | undefined
|
||||
let afterAssertion = (_input: PermissionV2.AssertInput): Effect.Effect<void> => Effect.void
|
||||
let afterRead = (_target: string, _content: Uint8Array): Effect.Effect<void> => Effect.void
|
||||
|
||||
const permission = Layer.succeed(
|
||||
|
|
@ -30,9 +29,7 @@ const permission = Layer.succeed(
|
|||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
input.action === denyAction
|
||||
? Effect.fail(new PermissionV2.DeniedError({ rules: [] }))
|
||||
: afterAssertion(input),
|
||||
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
|
|
@ -48,7 +45,6 @@ const reset = () => {
|
|||
writes.length = 0
|
||||
reads = 0
|
||||
denyAction = undefined
|
||||
afterAssertion = () => Effect.void
|
||||
afterRead = () => Effect.void
|
||||
}
|
||||
|
||||
|
|
@ -68,6 +64,10 @@ const filesystem = Layer.effect(
|
|||
),
|
||||
writeWithDirs: (target, content, mode) =>
|
||||
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))),
|
||||
writeFile: (target, content, options) =>
|
||||
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeFile(target, content, options))),
|
||||
writeFileString: (target, content, options) =>
|
||||
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeFileString(target, content, options))),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(FSUtil.defaultLayer))
|
||||
|
|
@ -77,18 +77,18 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
|
|||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
)
|
||||
const planning = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
|
||||
const commits = FileMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(planning))
|
||||
const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
|
||||
const mutation = FileMutation.layer.pipe(Layer.provide(filesystem))
|
||||
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
|
||||
const edit = EditTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(planning),
|
||||
Layer.provide(commits),
|
||||
Layer.provide(resolution),
|
||||
Layer.provide(mutation),
|
||||
Layer.provide(filesystem),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
return yield* body(yield* ToolRegistry.Service)
|
||||
}).pipe(Effect.provide(Layer.mergeAll(registry, planning, commits, edit)))
|
||||
}).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, edit)))
|
||||
}
|
||||
|
||||
const call = (input: typeof EditTool.Parameters.Type, id = "call-edit") => ({
|
||||
|
|
@ -384,55 +384,6 @@ describe("EditTool", () => {
|
|||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
it.live("delegates post-approval revalidation to FileMutation before writing", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([active, outside]) => {
|
||||
reset()
|
||||
const parent = path.join(active.path, "parent")
|
||||
const detached = path.join(active.path, "detached")
|
||||
afterAssertion = (input) =>
|
||||
input.action === "edit"
|
||||
? Effect.promise(async () => {
|
||||
await fs.rename(parent, detached)
|
||||
await fs.symlink(outside.path, parent)
|
||||
})
|
||||
: Effect.void
|
||||
return Effect.promise(async () => {
|
||||
await fs.mkdir(parent)
|
||||
await fs.writeFile(path.join(parent, "escape.txt"), "before")
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
withTool(active.path, (registry) =>
|
||||
registry.execute(call({ path: "parent/escape.txt", oldString: "before", newString: "after" })),
|
||||
),
|
||||
),
|
||||
Effect.andThen((result) =>
|
||||
Effect.gen(function* () {
|
||||
expect(result).toEqual({ type: "error", value: "Unable to edit parent/escape.txt" })
|
||||
expect(assertions.map((input) => input.action)).toEqual(["edit"])
|
||||
expect(writes).toEqual([])
|
||||
expect(
|
||||
yield* Effect.promise(() =>
|
||||
fs.stat(path.join(outside.path, "escape.txt")).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
),
|
||||
).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
([active, outside]) =>
|
||||
Effect.promise(() =>
|
||||
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps the locked edit schema, semantics docstring, and deferred TODOs visible", async () => {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ const sessionID = SessionV2.ID.make("ses_write_tool_test")
|
|||
const assertions: PermissionV2.AssertInput[] = []
|
||||
const writes: string[] = []
|
||||
let denyAction: string | undefined
|
||||
let afterAssertion = (_input: PermissionV2.AssertInput): Effect.Effect<void> => Effect.void
|
||||
|
||||
const permission = Layer.succeed(
|
||||
PermissionV2.Service,
|
||||
|
|
@ -28,9 +27,7 @@ const permission = Layer.succeed(
|
|||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
input.action === denyAction
|
||||
? Effect.fail(new PermissionV2.DeniedError({ rules: [] }))
|
||||
: afterAssertion(input),
|
||||
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
|
|
@ -45,7 +42,6 @@ const reset = () => {
|
|||
assertions.length = 0
|
||||
writes.length = 0
|
||||
denyAction = undefined
|
||||
afterAssertion = () => Effect.void
|
||||
}
|
||||
|
||||
const filesystem = Layer.effect(
|
||||
|
|
@ -65,13 +61,13 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
|
|||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
)
|
||||
const planning = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
|
||||
const commits = FileMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(planning))
|
||||
const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
|
||||
const mutation = FileMutation.layer.pipe(Layer.provide(filesystem))
|
||||
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
|
||||
const write = WriteTool.layer.pipe(Layer.provide(registry), Layer.provide(planning), Layer.provide(commits))
|
||||
const write = WriteTool.layer.pipe(Layer.provide(registry), Layer.provide(resolution), Layer.provide(mutation))
|
||||
return Effect.gen(function* () {
|
||||
return yield* body(yield* ToolRegistry.Service)
|
||||
}).pipe(Effect.provide(Layer.mergeAll(registry, planning, commits, write)))
|
||||
}).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, write)))
|
||||
}
|
||||
|
||||
const call = (input: typeof WriteTool.Parameters.Type, id = "call-write") => ({
|
||||
|
|
@ -258,51 +254,6 @@ describe("WriteTool", () => {
|
|||
),
|
||||
),
|
||||
)
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
it.live("delegates post-approval revalidation to FileMutation before writing", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([active, outside]) => {
|
||||
reset()
|
||||
const parent = path.join(active.path, "parent")
|
||||
afterAssertion = (input) =>
|
||||
input.action === "edit"
|
||||
? Effect.promise(async () => {
|
||||
await fs.rmdir(parent)
|
||||
await fs.symlink(outside.path, parent)
|
||||
})
|
||||
: Effect.void
|
||||
return Effect.promise(() => fs.mkdir(parent)).pipe(
|
||||
Effect.andThen(
|
||||
withTool(active.path, (registry) =>
|
||||
registry.execute(call({ path: "parent/escape.txt", content: "blocked" })),
|
||||
),
|
||||
),
|
||||
Effect.andThen((result) =>
|
||||
Effect.gen(function* () {
|
||||
expect(result).toEqual({ type: "error", value: "Unable to write parent/escape.txt" })
|
||||
expect(assertions.map((input) => input.action)).toEqual(["edit"])
|
||||
expect(writes).toEqual([])
|
||||
expect(
|
||||
yield* Effect.promise(() =>
|
||||
fs.stat(path.join(outside.path, "escape.txt")).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
),
|
||||
).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
([active, outside]) =>
|
||||
Effect.promise(() =>
|
||||
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps the locked write schema, semantics docstring, and deferred UX TODOs visible", async () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue