refactor(core): simplify filesystem mutation protocol (#31059)

This commit is contained in:
Kit Langton 2026-06-05 23:08:23 -04:00 committed by GitHub
commit ceccde7e84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 356 additions and 623 deletions

View file

@ -41,25 +41,13 @@ const definition = Tool.make({
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
})
type Planned = { readonly hunk: Patch.Hunk; readonly plan: LocationMutation.Plan }
type Prepared =
| {
readonly type: "add"
readonly hunk: Extract<Patch.Hunk, { readonly type: "add" }>
readonly plan: LocationMutation.Plan
}
| {
readonly type: "delete"
readonly hunk: Extract<Patch.Hunk, { readonly type: "delete" }>
readonly plan: LocationMutation.Plan
}
| {
readonly type: "update"
readonly hunk: Extract<Patch.Hunk, { readonly type: "update" }>
readonly plan: LocationMutation.Plan
| (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & { readonly target: LocationMutation.Target })
| (Extract<Patch.Hunk, { readonly type: "update" }> & {
readonly target: LocationMutation.Target
readonly source: Uint8Array
readonly content: string
}
})
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
@ -90,12 +78,12 @@ export const layer = Layer.effectDiscard(
const move = hunks.find((hunk) => hunk.type === "update" && hunk.movePath !== undefined)
if (move) return yield* new ToolFailure({ message: "apply_patch moves are not supported yet" })
const planned: Planned[] = []
const targets: Array<{ readonly hunk: Patch.Hunk; readonly target: LocationMutation.Target }> = []
for (const hunk of hunks)
planned.push({ hunk, plan: yield* mutation.resolve({ path: hunk.path, kind: "file" }) })
targets.push({ hunk, target: yield* mutation.resolve({ path: hunk.path, kind: "file" }) })
const externalDirectories = new Map<string, LocationMutation.ExternalDirectoryAuthorization>()
for (const { plan } of planned) {
const external = plan.target.externalDirectory
for (const { target } of targets) {
const external = target.externalDirectory
if (external) externalDirectories.set(external.resource, external)
}
for (const external of externalDirectories.values()) {
@ -103,64 +91,66 @@ export const layer = Layer.effectDiscard(
}
yield* assertPermission({
action: "edit",
resources: [...new Set(planned.map(({ plan }) => plan.target.resource))],
resources: [...new Set(targets.map(({ target }) => target.resource))],
save: ["*"],
})
const prepared: Prepared[] = []
for (const { hunk, plan } of planned) {
if (hunk.type === "add") {
const target = yield* mutation.revalidate(plan)
if (target.exists) return yield* fail(hunk.path, new Error("Target file already exists"))
prepared.push({ type: hunk.type, hunk, plan })
continue
}
const target = yield* mutation.revalidate(plan)
if (!target.exists || target.type !== "File")
return yield* fail(hunk.path, new Error("Target file does not exist"))
if (hunk.type === "delete") {
prepared.push({ type: hunk.type, hunk, plan })
continue
}
const source = yield* fs.readFile(target.canonical)
const update = Patch.derive(
hunk.path,
hunk.chunks,
new TextDecoder("utf-8", { ignoreBOM: true }).decode(source),
)
prepared.push({ type: hunk.type, hunk, plan, source, content: Patch.joinBom(update.content, update.bom) })
for (const { hunk, target } of targets) {
yield* Effect.gen(function* () {
if (hunk.type === "add") {
prepared.push({ ...hunk, target })
return
}
if ((yield* fs.stat(target.canonical)).type !== "File")
yield* fail(hunk.path, new Error("Target file does not exist"))
if (hunk.type === "delete") {
prepared.push({ ...hunk, target })
return
}
const source = yield* fs.readFile(target.canonical)
const update = Patch.derive(
hunk.path,
hunk.chunks,
new TextDecoder("utf-8", { ignoreBOM: true }).decode(source),
)
prepared.push({
...hunk,
target,
source,
content: Patch.joinBom(update.content, update.bom),
})
}).pipe(Effect.catchCause((cause) => Effect.fail(fail(hunk.path, Cause.squash(cause)))))
}
yield* Effect.uninterruptible(
Effect.forEach(
prepared,
(change) =>
Effect.gen(function* () {
if (change.type === "add") {
const result = yield* files.create({
plan: change.plan,
content:
change.hunk.contents.endsWith("\n") || change.hunk.contents === ""
? change.hunk.contents
: `${change.hunk.contents}\n`,
})
applied.push({ type: change.type, resource: result.resource, target: result.target })
return
}
if (change.type === "delete") {
const result = yield* files.remove({ plan: change.plan })
applied.push({ type: change.type, resource: result.resource, target: result.target })
return
}
const result = yield* files.writeIfUnchanged({
plan: change.plan,
expected: change.source,
content: change.content,
yield* Effect.forEach(
prepared,
(change) =>
Effect.gen(function* () {
if (change.type === "add") {
const result = yield* files.create({
target: change.target,
content:
change.contents.endsWith("\n") || change.contents === ""
? change.contents
: `${change.contents}\n`,
})
applied.push({ type: change.type, resource: result.resource, target: result.target })
}).pipe(Effect.catchCause((cause) => Effect.fail(fail(change.hunk.path, Cause.squash(cause))))),
{ discard: true },
),
return
}
if (change.type === "delete") {
const result = yield* files.remove({ target: change.target })
applied.push({ type: change.type, resource: result.resource, target: result.target })
return
}
const result = yield* files.writeIfUnchanged({
target: change.target,
expected: change.source,
content: change.content,
})
applied.push({ type: change.type, resource: result.resource, target: result.target })
}).pipe(Effect.catchCause((cause) => Effect.fail(fail(change.path, Cause.squash(cause))))),
{ discard: true },
)
return { applied }
}).pipe(

View file

@ -114,6 +114,7 @@ export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const mutation = yield* LocationMutation.Service
const fs = yield* FSUtil.Service
const appProcess = yield* AppProcess.Service
const resources = yield* ToolOutputStore.Service
const config = yield* Config.Service
@ -124,17 +125,16 @@ export const layer = Layer.effectDiscard(
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
execute: ({ parameters, sessionID, call, assertPermission }) =>
Effect.gen(function* () {
const plan = yield* mutation.resolve({ path: parameters.workdir ?? ".", kind: "directory" })
const external = plan.target.externalDirectory
const target = yield* mutation.resolve({ path: parameters.workdir ?? ".", kind: "directory" })
const external = target.externalDirectory
if (external) yield* assertPermission(LocationMutation.externalDirectoryPermission(external))
const warnings = externalCommandDirectories(parameters.command, plan.target.canonical).map(
const warnings = externalCommandDirectories(parameters.command, target.canonical).map(
(directory) =>
`Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Bash runs with host-user filesystem, process, and network authority; this scan is advisory only.`,
)
yield* assertPermission({ action: name, resources: [parameters.command], save: [parameters.command] })
const target = yield* mutation.revalidate(plan)
if (!target.exists || target.type !== "Directory")
if ((yield* fs.stat(target.canonical)).type !== "Directory")
throw new Error(`Working directory is not a directory: ${target.canonical}`)
const entries = yield* config.entries()

View file

@ -130,15 +130,14 @@ export const layer = Layer.effectDiscard(
})
}
const plan = yield* unableToEdit(mutation.resolve({ path: parameters.path, kind: "file" }))
const external = plan.target.externalDirectory
const target = yield* unableToEdit(mutation.resolve({ path: parameters.path, kind: "file" }))
const external = target.externalDirectory
if (external) {
yield* unableToEdit(assertPermission(LocationMutation.externalDirectoryPermission(external)))
}
yield* unableToEdit(assertPermission({ action: "edit", resources: [plan.target.resource], save: ["*"] }))
const readable = yield* unableToEdit(mutation.revalidate(plan))
const source = decodeUtf8(yield* unableToEdit(fs.readFile(readable.canonical)))
yield* unableToEdit(assertPermission({ action: "edit", resources: [target.resource], save: ["*"] }))
const source = decodeUtf8(yield* unableToEdit(fs.readFile(target.canonical)))
const ending = detectLineEnding(source.text)
const oldString = convertToLineEnding(parameters.oldString, ending)
const newString = convertToLineEnding(parameters.newString, ending)
@ -163,7 +162,7 @@ export const layer = Layer.effectDiscard(
const next = splitBom(replaced)
const result = yield* unableToEdit(
files.writeIfUnchanged({
plan,
target,
expected: source.content,
content: joinBom(next.text, source.bom || next.bom),
}),

View file

@ -60,11 +60,11 @@ export const layer = Layer.effectDiscard(
tool: definition,
execute: ({ parameters, assertPermission }) =>
Effect.gen(function* () {
const plan = yield* mutation.resolve({ path: parameters.path, kind: "file" })
const external = plan.target.externalDirectory
const target = yield* mutation.resolve({ path: parameters.path, kind: "file" })
const external = target.externalDirectory
if (external) yield* assertPermission(LocationMutation.externalDirectoryPermission(external))
yield* assertPermission({ action: "edit", resources: [plan.target.resource], save: ["*"] })
return yield* files.writeTextPreservingBom({ plan, content: parameters.content })
yield* assertPermission({ action: "edit", resources: [target.resource], save: ["*"] })
return yield* files.writeTextPreservingBom({ target, content: parameters.content })
}).pipe(
Effect.catchCause((cause) =>
Effect.fail(