refactor: replace Filesystem util with AppFileSystem service (#20127)

pull/20147/head
Kit Langton 2026-03-30 17:48:28 -04:00 committed by GitHub
parent 48e97b47af
commit e6f6f7aff1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 76 additions and 91 deletions

View File

@ -393,7 +393,7 @@ export namespace Agent {
)
export const defaultLayer = layer.pipe(
Layer.provide(Auth.layer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer),
)

View File

@ -3,7 +3,7 @@ import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
import { makeRuntime } from "@/effect/run-service"
import { zod } from "@/util/effect-zod"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
import { AppFileSystem } from "../filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
@ -53,17 +53,13 @@ export namespace Auth {
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("Auth.all")(() =>
Effect.tryPromise({
try: async () => {
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
},
catch: fail("Failed to read auth data"),
}),
)
const all = Effect.fn("Auth.all")(function* () {
const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record<string, unknown>
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
})
const get = Effect.fn("Auth.get")(function* (providerID: string) {
return (yield* all())[providerID]
@ -74,10 +70,7 @@ export namespace Auth {
const data = yield* all()
if (norm !== key) delete data[key]
delete data[norm + "/"]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
catch: fail("Failed to write auth data"),
})
yield* fsys.writeJson(file, { ...data, [norm]: info }, 0o600).pipe(Effect.mapError(fail("Failed to write auth data")))
})
const remove = Effect.fn("Auth.remove")(function* (key: string) {
@ -85,17 +78,16 @@ export namespace Auth {
const data = yield* all()
delete data[key]
delete data[norm]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, data, 0o600),
catch: fail("Failed to write auth data"),
})
yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data")))
})
return Service.of({ get, all, set, remove })
}),
)
const { runPromise } = makeRuntime(Service, layer)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function get(providerID: string) {
return runPromise((service) => service.get(providerID))

View File

@ -1540,7 +1540,7 @@ export namespace Config {
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Auth.layer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Account.defaultLayer),
)

View File

@ -541,7 +541,7 @@ export namespace File {
const exists = yield* appFs.existsSafe(full)
if (!exists) return { type: "text" as const, content: "" }
const mimeType = Filesystem.mimeType(full)
const mimeType = AppFileSystem.mimeType(full)
const encode = knownText ? false : shouldEncode(mimeType)
if (encode && !isImage(mimeType)) return { type: "binary" as const, content: "", mimeType }

View File

@ -1,9 +1,9 @@
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
import { DateTime, Effect, Layer, Option, Semaphore, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log"
export namespace FileTime {
@ -12,21 +12,9 @@ export namespace FileTime {
export type Stamp = {
readonly read: Date
readonly mtime: number | undefined
readonly ctime: number | undefined
readonly size: number | undefined
}
const stamp = Effect.fnUntraced(function* (file: string) {
const stat = Filesystem.stat(file)
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
return {
read: yield* DateTime.nowAsDate,
mtime: stat?.mtime?.getTime(),
ctime: stat?.ctime?.getTime(),
size,
}
})
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
const value = reads.get(sessionID)
if (value) return value
@ -53,7 +41,17 @@ export namespace FileTime {
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
const stamp = Effect.fnUntraced(function* (file: string) {
const info = yield* fsys.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
return {
read: yield* DateTime.nowAsDate,
mtime: info ? Option.getOrUndefined(info.mtime)?.getTime() : undefined,
size: info ? Number(info.size) : undefined,
}
})
const state = yield* InstanceState.make<State>(
Effect.fn("FileTime.state")(() =>
Effect.succeed({
@ -92,7 +90,7 @@ export namespace FileTime {
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
const next = yield* stamp(filepath)
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
const changed = next.mtime !== time.mtime || next.size !== time.size
if (!changed) return
throw new Error(
@ -108,7 +106,9 @@ export namespace FileTime {
}),
).pipe(Layer.orDie)
const { runPromise } = makeRuntime(Service, layer)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export function read(sessionID: SessionID, file: string) {
return runPromise((s) => s.read(sessionID, file))

View File

@ -230,7 +230,7 @@ export namespace ProviderAuth {
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
export const defaultLayer = layer.pipe(Layer.provide(Auth.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)

View File

@ -1704,7 +1704,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
Layer.provide(Permission.layer),
Layer.provide(MCP.defaultLayer),
Layer.provide(LSP.defaultLayer),
Layer.provide(FileTime.layer),
Layer.provide(FileTime.defaultLayer),
Layer.provide(ToolRegistry.defaultLayer),
Layer.provide(Truncate.layer),
Layer.provide(AppFileSystem.defaultLayer),

View File

@ -11,7 +11,7 @@ import { makeRuntime } from "@/effect/run-service"
import { Flag } from "@/flag/flag"
import { Global } from "@/global"
import { Permission } from "@/permission"
import { Filesystem } from "@/util/filesystem"
import { AppFileSystem } from "@/filesystem"
import { Config } from "../config/config"
import { ConfigMarkdown } from "../config/markdown"
import { Glob } from "../util/glob"
@ -139,28 +139,20 @@ export namespace Skill {
config: Config.Interface,
discovery: Discovery.Interface,
bus: Bus.Interface,
fsys: AppFileSystem.Interface,
directory: string,
worktree: string,
) {
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
for (const dir of EXTERNAL_DIRS) {
const root = path.join(Global.Path.home, dir)
const isDir = yield* Effect.promise(() => Filesystem.isDir(root))
if (!isDir) continue
if (!(yield* fsys.isDir(root))) continue
yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
}
const upDirs = yield* Effect.promise(async () => {
const dirs: string[] = []
for await (const root of Filesystem.up({
targets: EXTERNAL_DIRS,
start: directory,
stop: worktree,
})) {
dirs.push(root)
}
return dirs
})
const upDirs = yield* fsys
.up({ targets: EXTERNAL_DIRS, start: directory, stop: worktree })
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
for (const root of upDirs) {
yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
@ -176,8 +168,7 @@ export namespace Skill {
for (const item of cfg.skills?.paths ?? []) {
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
const isDir = yield* Effect.promise(() => Filesystem.isDir(dir))
if (!isDir) {
if (!(yield* fsys.isDir(dir))) {
log.warn("skill path not found", { path: dir })
continue
}
@ -198,50 +189,52 @@ export namespace Skill {
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
export const layer: Layer.Layer<Service, never, Discovery.Service | Config.Service | Bus.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const discovery = yield* Discovery.Service
const config = yield* Config.Service
const bus = yield* Bus.Service
const state = yield* InstanceState.make(
Effect.fn("Skill.state")(function* (ctx) {
const s: State = { skills: {}, dirs: new Set() }
yield* loadSkills(s, config, discovery, bus, ctx.directory, ctx.worktree)
return s
}),
)
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const discovery = yield* Discovery.Service
const config = yield* Config.Service
const bus = yield* Bus.Service
const fsys = yield* AppFileSystem.Service
const state = yield* InstanceState.make(
Effect.fn("Skill.state")(function* (ctx) {
const s: State = { skills: {}, dirs: new Set() }
yield* loadSkills(s, config, discovery, bus, fsys, ctx.directory, ctx.worktree)
return s
}),
)
const get = Effect.fn("Skill.get")(function* (name: string) {
const s = yield* InstanceState.get(state)
return s.skills[name]
})
const get = Effect.fn("Skill.get")(function* (name: string) {
const s = yield* InstanceState.get(state)
return s.skills[name]
})
const all = Effect.fn("Skill.all")(function* () {
const s = yield* InstanceState.get(state)
return Object.values(s.skills)
})
const all = Effect.fn("Skill.all")(function* () {
const s = yield* InstanceState.get(state)
return Object.values(s.skills)
})
const dirs = Effect.fn("Skill.dirs")(function* () {
const s = yield* InstanceState.get(state)
return Array.from(s.dirs)
})
const dirs = Effect.fn("Skill.dirs")(function* () {
const s = yield* InstanceState.get(state)
return Array.from(s.dirs)
})
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
const s = yield* InstanceState.get(state)
const list = Object.values(s.skills).toSorted((a, b) => a.name.localeCompare(b.name))
if (!agent) return list
return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny")
})
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
const s = yield* InstanceState.get(state)
const list = Object.values(s.skills).toSorted((a, b) => a.name.localeCompare(b.name))
if (!agent) return list
return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny")
})
return Service.of({ get, all, dirs, available })
}),
)
return Service.of({ get, all, dirs, available })
}),
)
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
export const defaultLayer = layer.pipe(
Layer.provide(Discovery.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(AppFileSystem.defaultLayer),
)
export function fmt(list: Info[], opts: { verbose: boolean }) {