refactor(core): trim hosted read round trips and share graph utilities
This commit is contained in:
@@ -218,20 +218,19 @@ const hostedLayer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const env = yield* WorkspaceEnvironment.Service
|
||||
const mapFileSystemError = (method: string) => (error: WorkspaceEnvironment.Error) =>
|
||||
new FSUtil.FileSystemError({ method, cause: error })
|
||||
return make({
|
||||
readOptional: (path) =>
|
||||
env.files.read(path).pipe(
|
||||
Effect.catchTag("WorkspaceEnvironment.NotFoundError", () => Effect.succeed(undefined)),
|
||||
Effect.mapError(mapFileSystemError("read")),
|
||||
Effect.mapError(WorkspaceEnvironment.toFileSystemError("read")),
|
||||
),
|
||||
isDirectory: (path) =>
|
||||
env.files.stat(path).pipe(
|
||||
Effect.map((info) => info.type === "Directory"),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
),
|
||||
write: (path, content) => env.files.write(path, content).pipe(Effect.mapError(mapFileSystemError("write"))),
|
||||
write: (path, content) =>
|
||||
env.files.write(path, content).pipe(Effect.mapError(WorkspaceEnvironment.toFileSystemError("write"))),
|
||||
remove: (path) =>
|
||||
env.files
|
||||
.remove(path)
|
||||
@@ -239,7 +238,7 @@ const hostedLayer = Layer.effect(
|
||||
Effect.mapError((error) =>
|
||||
error._tag === "WorkspaceEnvironment.NotFoundError"
|
||||
? new NotFoundError({ path })
|
||||
: mapFileSystemError("remove")(error),
|
||||
: WorkspaceEnvironment.toFileSystemError("remove")(error),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
@@ -111,6 +111,10 @@ const mapGrepError = (error: unknown) =>
|
||||
|
||||
export const Event = FileSystem.Event
|
||||
|
||||
/** Directories first, then lexicographic; shared by every entry listing. */
|
||||
export const compareEntries = (a: Entry, b: Entry) =>
|
||||
a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1
|
||||
|
||||
export interface Interface {
|
||||
readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
|
||||
@@ -224,7 +228,7 @@ const makeEntries = (location: Location.Interface, root: string, backend: EntryB
|
||||
}),
|
||||
]
|
||||
})
|
||||
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1))
|
||||
.sort(compareEntries)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,10 +88,9 @@ export const Plugin = {
|
||||
// is discovered); for a file it starts at the file's dirname. External reads are
|
||||
// skipped, and discovery failures never fail the read.
|
||||
yield* Effect.gen(function* () {
|
||||
if (target.externalDirectory !== undefined) return
|
||||
// Discovery walks the host filesystem; hosted instruction
|
||||
// discovery needs an environment-backed walk first.
|
||||
if (location.workspaceID !== undefined) return
|
||||
// External reads skip discovery, and so do hosted Locations:
|
||||
// the walk reads the host filesystem.
|
||||
if (external !== undefined || location.workspaceID !== undefined) return
|
||||
const resolved = yield* fs.resolve(target.canonical)
|
||||
const root = yield* fs.resolve(location.directory)
|
||||
// up() searches its stop directory, so the Location-root AGENTS.md (already
|
||||
|
||||
@@ -111,6 +111,8 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
|
||||
|
||||
type FileType = WorkspaceEnvironment.FileInfo["type"]
|
||||
|
||||
/** The filesystem surface `read` consumes; FSUtil satisfies it structurally. */
|
||||
export interface ReadSource {
|
||||
readonly realPath: (path: string) => Effect.Effect<string, FSUtil.Error>
|
||||
@@ -119,10 +121,7 @@ export interface ReadSource {
|
||||
options: { readonly flag: "r" },
|
||||
) => Effect.Effect<
|
||||
{
|
||||
readonly stat: Effect.Effect<
|
||||
{ readonly type: WorkspaceEnvironment.FileInfo["type"]; readonly size: bigint | number },
|
||||
FSUtil.Error
|
||||
>
|
||||
readonly stat: Effect.Effect<{ readonly type: FileType; readonly size: bigint | number }, FSUtil.Error>
|
||||
readonly readAlloc: (bytes: number) => Effect.Effect<Option.Option<Uint8Array>, FSUtil.Error>
|
||||
},
|
||||
FSUtil.Error,
|
||||
@@ -191,11 +190,7 @@ const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array)
|
||||
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : decodeUtf8(resource, decoder, bytes)
|
||||
|
||||
export const inspect = Effect.fn("ReadTool.inspect")(function* (
|
||||
fs: {
|
||||
readonly stat: (
|
||||
path: string,
|
||||
) => Effect.Effect<{ readonly type: WorkspaceEnvironment.FileInfo["type"] }, FSUtil.Error>
|
||||
},
|
||||
fs: { readonly stat: (path: string) => Effect.Effect<{ readonly type: FileType }, FSUtil.Error> },
|
||||
input: string,
|
||||
) {
|
||||
const info = yield* fs.stat(input)
|
||||
@@ -238,10 +233,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
||||
type: "file" as const,
|
||||
uri: pathToFileURL(real).href,
|
||||
name: path.basename(real),
|
||||
content: Buffer.concat(
|
||||
chunks.map((chunk) => Buffer.from(chunk)),
|
||||
total,
|
||||
).toString("base64"),
|
||||
content: Buffer.concat(chunks, total).toString("base64"),
|
||||
encoding: "base64" as const,
|
||||
mime,
|
||||
}
|
||||
@@ -362,8 +354,6 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
||||
export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, input: string, page: PageInput = {}) {
|
||||
const real = yield* fs.realPath(input)
|
||||
const items = yield* fs.readDirectoryEntries(real)
|
||||
const offset = page.offset || 1
|
||||
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
||||
const entries = yield* Effect.forEach(
|
||||
items,
|
||||
(item) =>
|
||||
@@ -381,16 +371,16 @@ export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface,
|
||||
}),
|
||||
{ concurrency: 16 },
|
||||
)
|
||||
const visible = entries.filter((item): item is FileSystem.Entry => item !== undefined)
|
||||
return pageEntries(visible, { offset, limit })
|
||||
return sortAndPage(
|
||||
entries.filter((item): item is FileSystem.Entry => item !== undefined),
|
||||
page,
|
||||
)
|
||||
})
|
||||
|
||||
const pageEntries = (entries: FileSystem.Entry[], page: PageInput) => {
|
||||
const sortAndPage = (entries: FileSystem.Entry[], page: PageInput) => {
|
||||
const offset = page.offset || 1
|
||||
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
||||
const visible = [...entries].sort((a, b) =>
|
||||
a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1,
|
||||
)
|
||||
const visible = entries.sort(FileSystem.compareEntries)
|
||||
const selected = visible.slice(offset - 1, offset - 1 + limit)
|
||||
const truncated = offset - 1 + selected.length < visible.length
|
||||
return new ListPage({
|
||||
@@ -425,36 +415,55 @@ const hostedLayer = Layer.effect(
|
||||
|
||||
// Absence surfaces as a platform NotFound so the read tool's missing-file
|
||||
// handling stays backend-agnostic; other failures become filesystem errors.
|
||||
const mapEnv =
|
||||
const mapError =
|
||||
(method: string) =>
|
||||
(error: WorkspaceEnvironment.Error | WorkspaceEnvironment.NotFoundError): FSUtil.Error =>
|
||||
error._tag === "WorkspaceEnvironment.NotFoundError"
|
||||
? systemError({ _tag: "NotFound", module: "FileSystem", method, pathOrDescriptor: error.path, cause: error })
|
||||
: new FSUtil.FileSystemError({ method, cause: error })
|
||||
: WorkspaceEnvironment.toFileSystemError(method)(error)
|
||||
|
||||
const stats = { stat: (target: string) => env.files.stat(target).pipe(Effect.mapError(mapError("stat"))) }
|
||||
|
||||
const source: ReadSource = {
|
||||
realPath: (target) => env.files.realPath(target).pipe(Effect.mapError(mapEnv("realPath"))),
|
||||
// Paths arrive canonical from LocationMutation.resolve, so identity
|
||||
// avoids a provider canonicalization round trip per read.
|
||||
realPath: Effect.succeed,
|
||||
// The environment has no ranged read, so each read call transfers the
|
||||
// whole file once and pages in memory. Happy-path opens are that one
|
||||
// transfer; only the failure path stats to classify a non-file target,
|
||||
// which the shared read reports as PathKindError.
|
||||
// TODO: Bound and page large hosted reads once environments expose file
|
||||
// size and ranged reads.
|
||||
open: (target) =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* env.files.stat(target).pipe(Effect.mapError(mapEnv("stat")))
|
||||
if (info.type !== "File")
|
||||
env.files.read(target).pipe(
|
||||
Effect.map((bytes) => {
|
||||
let cursor = 0
|
||||
return {
|
||||
stat: Effect.succeed({ type: info.type, size: 0 }),
|
||||
readAlloc: () => Effect.succeed(Option.none<Uint8Array>()),
|
||||
stat: Effect.succeed({ type: "File" as const, size: bytes.length }),
|
||||
readAlloc: (size: number) =>
|
||||
Effect.sync(() => {
|
||||
if (cursor >= bytes.length) return Option.none<Uint8Array>()
|
||||
const chunk = bytes.subarray(cursor, Math.min(cursor + size, bytes.length))
|
||||
cursor += chunk.length
|
||||
return Option.some(chunk)
|
||||
}),
|
||||
}
|
||||
const bytes = yield* env.files.read(target).pipe(Effect.mapError(mapEnv("read")))
|
||||
let cursor = 0
|
||||
return {
|
||||
stat: Effect.succeed({ type: info.type, size: bytes.length }),
|
||||
readAlloc: (size: number) =>
|
||||
Effect.sync(() => {
|
||||
if (cursor >= bytes.length) return Option.none<Uint8Array>()
|
||||
const chunk = bytes.subarray(cursor, Math.min(cursor + size, bytes.length))
|
||||
cursor += chunk.length
|
||||
return Option.some(chunk)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
}),
|
||||
Effect.catchTag("WorkspaceEnvironment.Error", (error) =>
|
||||
stats.stat(target).pipe(
|
||||
Effect.catch(() => Effect.fail(mapError("read")(error))),
|
||||
Effect.flatMap((info) =>
|
||||
info.type === "File"
|
||||
? Effect.fail(mapError("read")(error))
|
||||
: Effect.succeed({
|
||||
stat: Effect.succeed({ type: info.type, size: 0 }),
|
||||
readAlloc: () => Effect.succeed(Option.none<Uint8Array>()),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.catchTag("WorkspaceEnvironment.NotFoundError", (error) => Effect.fail(mapError("read")(error))),
|
||||
),
|
||||
}
|
||||
|
||||
// Matches the local list semantics: symlinks resolve and escape-filter
|
||||
@@ -471,9 +480,8 @@ const hostedLayer = Layer.effect(
|
||||
return undefined
|
||||
})
|
||||
|
||||
const hostedList = Effect.fn("ReadTool.list")(function* (input: string, page: PageInput = {}) {
|
||||
const real = yield* source.realPath(input)
|
||||
const items = yield* env.files.list(real).pipe(Effect.mapError(mapEnv("list")))
|
||||
const list = Effect.fn("ReadTool.list")(function* (input: string, page: PageInput = {}) {
|
||||
const items = yield* env.files.list(input).pipe(Effect.mapError(mapError("list")))
|
||||
const entries = yield* Effect.forEach(
|
||||
items,
|
||||
(item) =>
|
||||
@@ -482,7 +490,7 @@ const hostedLayer = Layer.effect(
|
||||
item.type === "file" || item.type === "directory"
|
||||
? item.type
|
||||
: item.type === "symlink"
|
||||
? yield* resolveSymlink(real, item.name)
|
||||
? yield* resolveSymlink(input, item.name)
|
||||
: undefined
|
||||
if (!type) return
|
||||
return FileSystem.Entry.make({
|
||||
@@ -492,17 +500,16 @@ const hostedLayer = Layer.effect(
|
||||
}),
|
||||
{ concurrency: 4 },
|
||||
)
|
||||
return pageEntries(
|
||||
return sortAndPage(
|
||||
entries.filter((item): item is FileSystem.Entry => item !== undefined),
|
||||
page,
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
inspect: (target) =>
|
||||
inspect({ stat: (statTarget) => env.files.stat(statTarget).pipe(Effect.mapError(mapEnv("stat"))) }, target),
|
||||
inspect: (target) => inspect(stats, target),
|
||||
read: (target, resource, page) => read(source, target, resource, page),
|
||||
list: (target, page) => hostedList(target, page),
|
||||
list,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as WorkspaceEnvironment from "./environment"
|
||||
|
||||
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { makeLocationNode, tags } from "@opencode-ai/util/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
@@ -18,6 +19,12 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Wor
|
||||
path: Schema.String,
|
||||
}) {}
|
||||
|
||||
/** Translate environment failures into the host filesystem error vocabulary. */
|
||||
export const toFileSystemError =
|
||||
(method: string) =>
|
||||
(cause: Error | NotFoundError): FSUtil.Error =>
|
||||
new FSUtil.FileSystemError({ method, cause })
|
||||
|
||||
/**
|
||||
* Wrap one driver promise, translating the driver's not-found signal into the
|
||||
* environment error vocabulary so drivers and fakes never construct it ad hoc.
|
||||
|
||||
@@ -41,36 +41,30 @@ const allowed: Record<string, string> = {
|
||||
|
||||
describe("hosted location graph", () => {
|
||||
test("location-scoped services do not use host primitives without justification", () => {
|
||||
const ref = Location.Ref.make({
|
||||
directory: AbsolutePath.make("/workspace"),
|
||||
workspaceID: Workspace.ID.make("wrk_graphguard000000000000000"),
|
||||
})
|
||||
const workspaceID = Workspace.ID.make("wrk_graphguard000000000000000")
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make("/workspace"), workspaceID })
|
||||
// Replacement targets may be bare layers in general; every hosted
|
||||
// replacement is a node today, and bare-layer targets have no walkable
|
||||
// dependencies anyway.
|
||||
const replaced = new Map<LayerNode.Node<unknown, unknown, any>, LayerNode.Node<unknown, unknown, any>>(
|
||||
hostedReplacements(ref, ref.workspaceID!).flatMap(([from, to]) =>
|
||||
"dependencies" in to ? [[from, to] as const] : [],
|
||||
),
|
||||
const replaced = new Map<LayerNode.AnyNode, LayerNode.AnyNode>(
|
||||
hostedReplacements(ref, workspaceID).flatMap(([from, to]) => (LayerNode.isNode(to) ? [[from, to] as const] : [])),
|
||||
)
|
||||
const resolve = (node: LayerNode.Node<unknown, unknown, any>) => replaced.get(node) ?? node
|
||||
const resolve = (node: LayerNode.AnyNode) => replaced.get(node) ?? node
|
||||
|
||||
const hostPrimitives = new Set<LayerNode.Node<unknown, unknown, any>>([FSUtil.node, AppProcess.node])
|
||||
const visited = new Set<LayerNode.Node<unknown, unknown, any>>()
|
||||
const hostPrimitives = new Set<LayerNode.AnyNode>([FSUtil.node, AppProcess.node])
|
||||
const offenders = new Map<string, string[]>()
|
||||
const queue: LayerNode.Node<unknown, unknown, any>[] = [locationServices]
|
||||
while (queue.length > 0) {
|
||||
const node = resolve(queue.pop()!)
|
||||
if (visited.has(node)) continue
|
||||
visited.add(node)
|
||||
for (const dependency of node.dependencies) {
|
||||
const target = resolve(dependency)
|
||||
if (hostPrimitives.has(target) && node.tag === Node.tags.values.location) {
|
||||
offenders.set(node.name, [...(offenders.get(node.name) ?? []), target.name])
|
||||
LayerNode.walk<void>(
|
||||
locationServices,
|
||||
(node, context) => {
|
||||
for (const dependency of node.dependencies) {
|
||||
if (hostPrimitives.has(resolve(dependency)) && node.tag === Node.tags.values.location) {
|
||||
offenders.set(node.name, [...(offenders.get(node.name) ?? []), resolve(dependency).name])
|
||||
}
|
||||
context.visit(dependency)
|
||||
}
|
||||
queue.push(target)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ resolve },
|
||||
)
|
||||
|
||||
const unjustified = [...offenders.entries()]
|
||||
.filter(([name]) => !(name in allowed))
|
||||
|
||||
@@ -82,12 +82,12 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag("Workspace.NotFoundError", (error) =>
|
||||
Effect.fail(new InvalidRequestError({ message: `Workspace not found: ${error.id}` })),
|
||||
),
|
||||
Effect.catchTag("Session.WorkspaceDirectoryError", (error) =>
|
||||
Effect.fail(new InvalidRequestError({ message: error.message })),
|
||||
),
|
||||
Effect.catchTags({
|
||||
"Workspace.NotFoundError": (error) =>
|
||||
Effect.fail(new InvalidRequestError({ message: `Workspace not found: ${error.id}` })),
|
||||
"Session.WorkspaceDirectoryError": (error) =>
|
||||
Effect.fail(new InvalidRequestError({ message: error.message })),
|
||||
}),
|
||||
Effect.orDie,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Brand, Context, Layer } from "effect"
|
||||
|
||||
type AnyNode = Node<unknown, unknown, any>
|
||||
export type AnyNode = Node<unknown, unknown, any>
|
||||
type RuntimeLayer = Layer.Layer<never, unknown, unknown>
|
||||
type NodeList<Item extends AnyNode = AnyNode> = readonly [] | readonly [Item, ...Item[]]
|
||||
export type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown, any> ? A : never
|
||||
@@ -155,7 +155,7 @@ function nodeMakeIdentity(node: AnyNode): NodeIdentity {
|
||||
return { name: node.name }
|
||||
}
|
||||
|
||||
function isNode(input: Layer.Any | AnyNode): input is AnyNode {
|
||||
export function isNode(input: Layer.Any | AnyNode): input is AnyNode {
|
||||
return "kind" in input && "dependencies" in input
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ type VisitContext<Result> = {
|
||||
readonly visit: (node: AnyNode) => Result
|
||||
}
|
||||
|
||||
function walk<Result>(
|
||||
export function walk<Result>(
|
||||
root: AnyNode,
|
||||
visit: Visit<Result>,
|
||||
options: {
|
||||
|
||||
Reference in New Issue
Block a user