Skip to content

Commit 81be544

Browse files
authored
feat(filesystem): add AppFileSystem service, migrate Snapshot (#18138)
1 parent 773c119 commit 81be544

File tree

6 files changed

+541
-25
lines changed

6 files changed

+541
-25
lines changed

packages/opencode/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"license": "MIT",
77
"private": true,
88
"scripts": {
9+
"prepare": "effect-language-service patch || true",
910
"typecheck": "tsgo --noEmit",
1011
"test": "bun test --timeout 30000",
1112
"build": "bun run script/build.ts",
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { NodeFileSystem } from "@effect/platform-node"
2+
import { dirname, join, relative, resolve as pathResolve } from "path"
3+
import { realpathSync } from "fs"
4+
import { lookup } from "mime-types"
5+
import { Effect, FileSystem, Layer, Schema, ServiceMap } from "effect"
6+
import type { PlatformError } from "effect/PlatformError"
7+
import { Glob } from "../util/glob"
8+
9+
export namespace AppFileSystem {
10+
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
11+
method: Schema.String,
12+
cause: Schema.optional(Schema.Defect),
13+
}) {}
14+
15+
export type Error = PlatformError | FileSystemError
16+
17+
export interface Interface extends FileSystem.FileSystem {
18+
readonly isDir: (path: string) => Effect.Effect<boolean, Error>
19+
readonly isFile: (path: string) => Effect.Effect<boolean, Error>
20+
readonly readJson: (path: string) => Effect.Effect<unknown, Error>
21+
readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
22+
readonly ensureDir: (path: string) => Effect.Effect<void, Error>
23+
readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
24+
readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
25+
readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
26+
readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
27+
readonly glob: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error>
28+
readonly globMatch: (pattern: string, filepath: string) => boolean
29+
}
30+
31+
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileSystem") {}
32+
33+
export const layer = Layer.effect(
34+
Service,
35+
Effect.gen(function* () {
36+
const fs = yield* FileSystem.FileSystem
37+
38+
const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
39+
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
40+
return info?.type === "Directory"
41+
})
42+
43+
const isFile = Effect.fn("FileSystem.isFile")(function* (path: string) {
44+
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
45+
return info?.type === "File"
46+
})
47+
48+
const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
49+
const text = yield* fs.readFileString(path)
50+
return JSON.parse(text)
51+
})
52+
53+
const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) {
54+
const content = JSON.stringify(data, null, 2)
55+
yield* fs.writeFileString(path, content)
56+
if (mode) yield* fs.chmod(path, mode)
57+
})
58+
59+
const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) {
60+
yield* fs.makeDirectory(path, { recursive: true })
61+
})
62+
63+
const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* (
64+
path: string,
65+
content: string | Uint8Array,
66+
mode?: number,
67+
) {
68+
const write = typeof content === "string" ? fs.writeFileString(path, content) : fs.writeFile(path, content)
69+
70+
yield* write.pipe(
71+
Effect.catchIf(
72+
(e) => e.reason._tag === "NotFound",
73+
() =>
74+
Effect.gen(function* () {
75+
yield* fs.makeDirectory(dirname(path), { recursive: true })
76+
yield* write
77+
}),
78+
),
79+
)
80+
if (mode) yield* fs.chmod(path, mode)
81+
})
82+
83+
const glob = Effect.fn("FileSystem.glob")(function* (pattern: string, options?: Glob.Options) {
84+
return yield* Effect.tryPromise({
85+
try: () => Glob.scan(pattern, options),
86+
catch: (cause) => new FileSystemError({ method: "glob", cause }),
87+
})
88+
})
89+
90+
const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) {
91+
const result: string[] = []
92+
let current = start
93+
while (true) {
94+
const search = join(current, target)
95+
if (yield* fs.exists(search)) result.push(search)
96+
if (stop === current) break
97+
const parent = dirname(current)
98+
if (parent === current) break
99+
current = parent
100+
}
101+
return result
102+
})
103+
104+
const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) {
105+
const result: string[] = []
106+
let current = options.start
107+
while (true) {
108+
for (const target of options.targets) {
109+
const search = join(current, target)
110+
if (yield* fs.exists(search)) result.push(search)
111+
}
112+
if (options.stop === current) break
113+
const parent = dirname(current)
114+
if (parent === current) break
115+
current = parent
116+
}
117+
return result
118+
})
119+
120+
const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) {
121+
const result: string[] = []
122+
let current = start
123+
while (true) {
124+
const matches = yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe(
125+
Effect.catch(() => Effect.succeed([] as string[])),
126+
)
127+
result.push(...matches)
128+
if (stop === current) break
129+
const parent = dirname(current)
130+
if (parent === current) break
131+
current = parent
132+
}
133+
return result
134+
})
135+
136+
return Service.of({
137+
...fs,
138+
isDir,
139+
isFile,
140+
readJson,
141+
writeJson,
142+
ensureDir,
143+
writeWithDirs,
144+
findUp,
145+
up,
146+
globUp,
147+
glob,
148+
globMatch: Glob.match,
149+
})
150+
}),
151+
)
152+
153+
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
154+
155+
// Pure helpers that don't need Effect (path manipulation, sync operations)
156+
export function mimeType(p: string): string {
157+
return lookup(p) || "application/octet-stream"
158+
}
159+
160+
export function normalizePath(p: string): string {
161+
if (process.platform !== "win32") return p
162+
try {
163+
return realpathSync.native(p)
164+
} catch {
165+
return p
166+
}
167+
}
168+
169+
export function resolve(p: string): string {
170+
const resolved = pathResolve(windowsPath(p))
171+
try {
172+
return normalizePath(realpathSync(resolved))
173+
} catch (e: any) {
174+
if (e?.code === "ENOENT") return normalizePath(resolved)
175+
throw e
176+
}
177+
}
178+
179+
export function windowsPath(p: string): string {
180+
if (process.platform !== "win32") return p
181+
return p
182+
.replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
183+
.replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
184+
.replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
185+
.replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
186+
}
187+
188+
export function overlaps(a: string, b: string) {
189+
const relA = relative(a, b)
190+
const relB = relative(b, a)
191+
return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
192+
}
193+
194+
export function contains(parent: string, child: string) {
195+
return !relative(parent, child).startsWith("..")
196+
}
197+
}

packages/opencode/src/skill/discovery.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { NodeFileSystem, NodePath } from "@effect/platform-node"
2-
import { Effect, FileSystem, Layer, Path, Schema, ServiceMap } from "effect"
1+
import { NodePath } from "@effect/platform-node"
2+
import { Effect, Layer, Path, Schema, ServiceMap } from "effect"
33
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
44
import { withTransientReadRetry } from "@/util/effect-http-client"
5+
import { AppFileSystem } from "@/filesystem"
56
import { Global } from "../global"
67
import { Log } from "../util/log"
78

@@ -24,12 +25,12 @@ export namespace Discovery {
2425

2526
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SkillDiscovery") {}
2627

27-
export const layer: Layer.Layer<Service, never, FileSystem.FileSystem | Path.Path | HttpClient.HttpClient> =
28+
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Path.Path | HttpClient.HttpClient> =
2829
Layer.effect(
2930
Service,
3031
Effect.gen(function* () {
3132
const log = Log.create({ service: "skill-discovery" })
32-
const fs = yield* FileSystem.FileSystem
33+
const fs = yield* AppFileSystem.Service
3334
const path = yield* Path.Path
3435
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
3536
const cache = path.join(Global.Path.cache, "skills")
@@ -40,11 +41,7 @@ export namespace Discovery {
4041
return yield* HttpClientRequest.get(url).pipe(
4142
http.execute,
4243
Effect.flatMap((res) => res.arrayBuffer),
43-
Effect.flatMap((body) =>
44-
fs
45-
.makeDirectory(path.dirname(dest), { recursive: true })
46-
.pipe(Effect.flatMap(() => fs.writeFile(dest, new Uint8Array(body)))),
47-
),
44+
Effect.flatMap((body) => fs.writeWithDirs(dest, new Uint8Array(body))),
4845
Effect.as(true),
4946
Effect.catch((err) =>
5047
Effect.sync(() => {
@@ -113,7 +110,7 @@ export namespace Discovery {
113110

114111
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
115112
Layer.provide(FetchHttpClient.layer),
116-
Layer.provide(NodeFileSystem.layer),
113+
Layer.provide(AppFileSystem.defaultLayer),
117114
Layer.provide(NodePath.layer),
118115
)
119116
}

packages/opencode/src/snapshot/index.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
2-
import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap, Stream } from "effect"
2+
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
33
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
44
import path from "path"
55
import z from "zod"
66
import { InstanceContext } from "@/effect/instance-context"
77
import { runPromiseInstance } from "@/effect/runtime"
8+
import { AppFileSystem } from "@/filesystem"
89
import { Config } from "../config/config"
910
import { Global } from "../global"
1011
import { Log } from "../util/log"
@@ -85,12 +86,12 @@ export namespace Snapshot {
8586
export const layer: Layer.Layer<
8687
Service,
8788
never,
88-
InstanceContext | FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner
89+
InstanceContext | AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner
8990
> = Layer.effect(
9091
Service,
9192
Effect.gen(function* () {
9293
const ctx = yield* InstanceContext
93-
const fs = yield* FileSystem.FileSystem
94+
const fs = yield* AppFileSystem.Service
9495
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
9596
const directory = ctx.directory
9697
const worktree = ctx.worktree
@@ -124,9 +125,8 @@ export namespace Snapshot {
124125
),
125126
)
126127

128+
// Snapshot-specific error handling on top of AppFileSystem
127129
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
128-
const mkdir = (dir: string) => fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
129-
const write = (file: string, text: string) => fs.writeFileString(file, text).pipe(Effect.orDie)
130130
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
131131
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
132132

@@ -148,12 +148,12 @@ export namespace Snapshot {
148148
const sync = Effect.fnUntraced(function* () {
149149
const file = yield* excludes()
150150
const target = path.join(gitdir, "info", "exclude")
151-
yield* mkdir(path.join(gitdir, "info"))
151+
yield* fs.ensureDir(path.join(gitdir, "info")).pipe(Effect.orDie)
152152
if (!file) {
153-
yield* write(target, "")
153+
yield* fs.writeFileString(target, "").pipe(Effect.orDie)
154154
return
155155
}
156-
yield* write(target, yield* read(file))
156+
yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
157157
})
158158

159159
const add = Effect.fnUntraced(function* () {
@@ -178,7 +178,7 @@ export namespace Snapshot {
178178
const track = Effect.fn("Snapshot.track")(function* () {
179179
if (!(yield* enabled())) return
180180
const existed = yield* exists(gitdir)
181-
yield* mkdir(gitdir)
181+
yield* fs.ensureDir(gitdir).pipe(Effect.orDie)
182182
if (!existed) {
183183
yield* git(["init"], {
184184
env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree },
@@ -342,7 +342,8 @@ export namespace Snapshot {
342342

343343
export const defaultLayer = layer.pipe(
344344
Layer.provide(NodeChildProcessSpawner.layer),
345-
Layer.provide(NodeFileSystem.layer),
345+
Layer.provide(AppFileSystem.defaultLayer),
346+
Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner
346347
Layer.provide(NodePath.layer),
347348
)
348349
}

packages/opencode/src/tool/truncate-effect.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { NodeFileSystem, NodePath } from "@effect/platform-node"
2-
import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap } from "effect"
1+
import { NodePath } from "@effect/platform-node"
2+
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect"
33
import path from "path"
44
import type { Agent } from "../agent/agent"
5+
import { AppFileSystem } from "@/filesystem"
56
import { PermissionNext } from "../permission"
67
import { Identifier } from "../id/id"
78
import { Log } from "../util/log"
@@ -44,7 +45,7 @@ export namespace TruncateEffect {
4445
export const layer = Layer.effect(
4546
Service,
4647
Effect.gen(function* () {
47-
const fs = yield* FileSystem.FileSystem
48+
const fs = yield* AppFileSystem.Service
4849

4950
const cleanup = Effect.fn("Truncate.cleanup")(function* () {
5051
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
@@ -101,7 +102,7 @@ export namespace TruncateEffect {
101102
const preview = out.join("\n")
102103
const file = path.join(TRUNCATION_DIR, ToolID.ascending())
103104

104-
yield* fs.makeDirectory(TRUNCATION_DIR, { recursive: true }).pipe(Effect.orDie)
105+
yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie)
105106
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
106107

107108
const hint = hasTaskTool(agent)
@@ -132,5 +133,5 @@ export namespace TruncateEffect {
132133
}),
133134
)
134135

135-
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
136+
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
136137
}

0 commit comments

Comments
 (0)