|
|
@@ -1,19 +1,17 @@
|
|
|
import { Log } from "../util/log"
|
|
|
import path from "path"
|
|
|
-import fs from "fs/promises"
|
|
|
import { Global } from "../global"
|
|
|
-import { Filesystem } from "../util/filesystem"
|
|
|
-import { lazy } from "../util/lazy"
|
|
|
-import { Lock } from "../util/lock"
|
|
|
import { NamedError } from "@opencode-ai/util/error"
|
|
|
import z from "zod"
|
|
|
-import { Glob } from "../util/glob"
|
|
|
import { git } from "@/util/git"
|
|
|
+import { AppFileSystem } from "@/filesystem"
|
|
|
+import { makeRuntime } from "@/effect/run-service"
|
|
|
+import { Effect, Exit, Layer, Option, RcMap, Schema, ServiceMap, TxReentrantLock } from "effect"
|
|
|
|
|
|
export namespace Storage {
|
|
|
const log = Log.create({ service: "storage" })
|
|
|
|
|
|
- type Migration = (dir: string) => Promise<void>
|
|
|
+ type Migration = (dir: string, fs: AppFileSystem.Interface) => Effect.Effect<void, AppFileSystem.Error>
|
|
|
|
|
|
export const NotFoundError = NamedError.create(
|
|
|
"NotFoundError",
|
|
|
@@ -22,36 +20,101 @@ export namespace Storage {
|
|
|
}),
|
|
|
)
|
|
|
|
|
|
+ export type Error = AppFileSystem.Error | InstanceType<typeof NotFoundError>
|
|
|
+
|
|
|
+ const RootFile = Schema.Struct({
|
|
|
+ path: Schema.optional(
|
|
|
+ Schema.Struct({
|
|
|
+ root: Schema.optional(Schema.String),
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ })
|
|
|
+
|
|
|
+ const SessionFile = Schema.Struct({
|
|
|
+ id: Schema.String,
|
|
|
+ })
|
|
|
+
|
|
|
+ const MessageFile = Schema.Struct({
|
|
|
+ id: Schema.String,
|
|
|
+ })
|
|
|
+
|
|
|
+ const DiffFile = Schema.Struct({
|
|
|
+ additions: Schema.Number,
|
|
|
+ deletions: Schema.Number,
|
|
|
+ })
|
|
|
+
|
|
|
+ const SummaryFile = Schema.Struct({
|
|
|
+ id: Schema.String,
|
|
|
+ projectID: Schema.String,
|
|
|
+ summary: Schema.Struct({ diffs: Schema.Array(DiffFile) }),
|
|
|
+ })
|
|
|
+
|
|
|
+ const decodeRoot = Schema.decodeUnknownOption(RootFile)
|
|
|
+ const decodeSession = Schema.decodeUnknownOption(SessionFile)
|
|
|
+ const decodeMessage = Schema.decodeUnknownOption(MessageFile)
|
|
|
+ const decodeSummary = Schema.decodeUnknownOption(SummaryFile)
|
|
|
+
|
|
|
+ export interface Interface {
|
|
|
+ readonly remove: (key: string[]) => Effect.Effect<void, AppFileSystem.Error>
|
|
|
+ readonly read: <T>(key: string[]) => Effect.Effect<T, Error>
|
|
|
+ readonly update: <T>(key: string[], fn: (draft: T) => void) => Effect.Effect<T, Error>
|
|
|
+ readonly write: <T>(key: string[], content: T) => Effect.Effect<void, AppFileSystem.Error>
|
|
|
+ readonly list: (prefix: string[]) => Effect.Effect<string[][], AppFileSystem.Error>
|
|
|
+ }
|
|
|
+
|
|
|
+ export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Storage") {}
|
|
|
+
|
|
|
+ function file(dir: string, key: string[]) {
|
|
|
+ return path.join(dir, ...key) + ".json"
|
|
|
+ }
|
|
|
+
|
|
|
+ function missing(err: unknown) {
|
|
|
+ if (!err || typeof err !== "object") return false
|
|
|
+ if ("code" in err && err.code === "ENOENT") return true
|
|
|
+ if ("reason" in err && err.reason && typeof err.reason === "object" && "_tag" in err.reason) {
|
|
|
+ return err.reason._tag === "NotFound"
|
|
|
+ }
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ function parseMigration(text: string) {
|
|
|
+ const value = Number.parseInt(text, 10)
|
|
|
+ return Number.isNaN(value) ? 0 : value
|
|
|
+ }
|
|
|
+
|
|
|
const MIGRATIONS: Migration[] = [
|
|
|
- async (dir) => {
|
|
|
+ Effect.fn("Storage.migration.1")(function* (dir: string, fs: AppFileSystem.Interface) {
|
|
|
const project = path.resolve(dir, "../project")
|
|
|
- if (!(await Filesystem.isDir(project))) return
|
|
|
- const projectDirs = await Glob.scan("*", {
|
|
|
+ if (!(yield* fs.isDir(project))) return
|
|
|
+ const projectDirs = yield* fs.glob("*", {
|
|
|
cwd: project,
|
|
|
include: "all",
|
|
|
})
|
|
|
for (const projectDir of projectDirs) {
|
|
|
- const fullPath = path.join(project, projectDir)
|
|
|
- if (!(await Filesystem.isDir(fullPath))) continue
|
|
|
+ const full = path.join(project, projectDir)
|
|
|
+ if (!(yield* fs.isDir(full))) continue
|
|
|
log.info(`migrating project ${projectDir}`)
|
|
|
let projectID = projectDir
|
|
|
- const fullProjectDir = path.join(project, projectDir)
|
|
|
let worktree = "/"
|
|
|
|
|
|
if (projectID !== "global") {
|
|
|
- for (const msgFile of await Glob.scan("storage/session/message/*/*.json", {
|
|
|
- cwd: path.join(project, projectDir),
|
|
|
+ for (const msgFile of yield* fs.glob("storage/session/message/*/*.json", {
|
|
|
+ cwd: full,
|
|
|
absolute: true,
|
|
|
})) {
|
|
|
- const json = await Filesystem.readJson<any>(msgFile)
|
|
|
- worktree = json.path?.root
|
|
|
- if (worktree) break
|
|
|
+ const json = decodeRoot(yield* fs.readJson(msgFile), { onExcessProperty: "preserve" })
|
|
|
+ const root = Option.isSome(json) ? json.value.path?.root : undefined
|
|
|
+ if (!root) continue
|
|
|
+ worktree = root
|
|
|
+ break
|
|
|
}
|
|
|
if (!worktree) continue
|
|
|
- if (!(await Filesystem.isDir(worktree))) continue
|
|
|
- const result = await git(["rev-list", "--max-parents=0", "--all"], {
|
|
|
- cwd: worktree,
|
|
|
- })
|
|
|
+ if (!(yield* fs.isDir(worktree))) continue
|
|
|
+ const result = yield* Effect.promise(() =>
|
|
|
+ git(["rev-list", "--max-parents=0", "--all"], {
|
|
|
+ cwd: worktree,
|
|
|
+ }),
|
|
|
+ )
|
|
|
const [id] = result
|
|
|
.text()
|
|
|
.split("\n")
|
|
|
@@ -61,157 +124,230 @@ export namespace Storage {
|
|
|
if (!id) continue
|
|
|
projectID = id
|
|
|
|
|
|
- await Filesystem.writeJson(path.join(dir, "project", projectID + ".json"), {
|
|
|
- id,
|
|
|
- vcs: "git",
|
|
|
- worktree,
|
|
|
- time: {
|
|
|
- created: Date.now(),
|
|
|
- initialized: Date.now(),
|
|
|
- },
|
|
|
- })
|
|
|
+ yield* fs.writeWithDirs(
|
|
|
+ path.join(dir, "project", projectID + ".json"),
|
|
|
+ JSON.stringify(
|
|
|
+ {
|
|
|
+ id,
|
|
|
+ vcs: "git",
|
|
|
+ worktree,
|
|
|
+ time: {
|
|
|
+ created: Date.now(),
|
|
|
+ initialized: Date.now(),
|
|
|
+ },
|
|
|
+ },
|
|
|
+ null,
|
|
|
+ 2,
|
|
|
+ ),
|
|
|
+ )
|
|
|
|
|
|
log.info(`migrating sessions for project ${projectID}`)
|
|
|
- for (const sessionFile of await Glob.scan("storage/session/info/*.json", {
|
|
|
- cwd: fullProjectDir,
|
|
|
+ for (const sessionFile of yield* fs.glob("storage/session/info/*.json", {
|
|
|
+ cwd: full,
|
|
|
absolute: true,
|
|
|
})) {
|
|
|
const dest = path.join(dir, "session", projectID, path.basename(sessionFile))
|
|
|
- log.info("copying", {
|
|
|
- sessionFile,
|
|
|
- dest,
|
|
|
- })
|
|
|
- const session = await Filesystem.readJson<any>(sessionFile)
|
|
|
- await Filesystem.writeJson(dest, session)
|
|
|
- log.info(`migrating messages for session ${session.id}`)
|
|
|
- for (const msgFile of await Glob.scan(`storage/session/message/${session.id}/*.json`, {
|
|
|
- cwd: fullProjectDir,
|
|
|
+ log.info("copying", { sessionFile, dest })
|
|
|
+ const session = yield* fs.readJson(sessionFile)
|
|
|
+ const info = decodeSession(session, { onExcessProperty: "preserve" })
|
|
|
+ yield* fs.writeWithDirs(dest, JSON.stringify(session, null, 2))
|
|
|
+ if (Option.isNone(info)) continue
|
|
|
+ log.info(`migrating messages for session ${info.value.id}`)
|
|
|
+ for (const msgFile of yield* fs.glob(`storage/session/message/${info.value.id}/*.json`, {
|
|
|
+ cwd: full,
|
|
|
absolute: true,
|
|
|
})) {
|
|
|
- const dest = path.join(dir, "message", session.id, path.basename(msgFile))
|
|
|
+ const next = path.join(dir, "message", info.value.id, path.basename(msgFile))
|
|
|
log.info("copying", {
|
|
|
msgFile,
|
|
|
- dest,
|
|
|
+ dest: next,
|
|
|
})
|
|
|
- const message = await Filesystem.readJson<any>(msgFile)
|
|
|
- await Filesystem.writeJson(dest, message)
|
|
|
+ const message = yield* fs.readJson(msgFile)
|
|
|
+ const item = decodeMessage(message, { onExcessProperty: "preserve" })
|
|
|
+ yield* fs.writeWithDirs(next, JSON.stringify(message, null, 2))
|
|
|
+ if (Option.isNone(item)) continue
|
|
|
|
|
|
- log.info(`migrating parts for message ${message.id}`)
|
|
|
- for (const partFile of await Glob.scan(`storage/session/part/${session.id}/${message.id}/*.json`, {
|
|
|
- cwd: fullProjectDir,
|
|
|
+ log.info(`migrating parts for message ${item.value.id}`)
|
|
|
+ for (const partFile of yield* fs.glob(`storage/session/part/${info.value.id}/${item.value.id}/*.json`, {
|
|
|
+ cwd: full,
|
|
|
absolute: true,
|
|
|
})) {
|
|
|
- const dest = path.join(dir, "part", message.id, path.basename(partFile))
|
|
|
- const part = await Filesystem.readJson(partFile)
|
|
|
+ const out = path.join(dir, "part", item.value.id, path.basename(partFile))
|
|
|
+ const part = yield* fs.readJson(partFile)
|
|
|
log.info("copying", {
|
|
|
partFile,
|
|
|
- dest,
|
|
|
+ dest: out,
|
|
|
})
|
|
|
- await Filesystem.writeJson(dest, part)
|
|
|
+ yield* fs.writeWithDirs(out, JSON.stringify(part, null, 2))
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
- },
|
|
|
- async (dir) => {
|
|
|
- for (const item of await Glob.scan("session/*/*.json", {
|
|
|
+ }),
|
|
|
+ Effect.fn("Storage.migration.2")(function* (dir: string, fs: AppFileSystem.Interface) {
|
|
|
+ for (const item of yield* fs.glob("session/*/*.json", {
|
|
|
cwd: dir,
|
|
|
absolute: true,
|
|
|
})) {
|
|
|
- const session = await Filesystem.readJson<any>(item)
|
|
|
- if (!session.projectID) continue
|
|
|
- if (!session.summary?.diffs) continue
|
|
|
- const { diffs } = session.summary
|
|
|
- await Filesystem.write(path.join(dir, "session_diff", session.id + ".json"), JSON.stringify(diffs))
|
|
|
- await Filesystem.writeJson(path.join(dir, "session", session.projectID, session.id + ".json"), {
|
|
|
- ...session,
|
|
|
- summary: {
|
|
|
- additions: diffs.reduce((sum: any, x: any) => sum + x.additions, 0),
|
|
|
- deletions: diffs.reduce((sum: any, x: any) => sum + x.deletions, 0),
|
|
|
- },
|
|
|
- })
|
|
|
+ const raw = yield* fs.readJson(item)
|
|
|
+ const session = decodeSummary(raw, { onExcessProperty: "preserve" })
|
|
|
+ if (Option.isNone(session)) continue
|
|
|
+ const diffs = session.value.summary.diffs
|
|
|
+ yield* fs.writeWithDirs(
|
|
|
+ path.join(dir, "session_diff", session.value.id + ".json"),
|
|
|
+ JSON.stringify(diffs, null, 2),
|
|
|
+ )
|
|
|
+ yield* fs.writeWithDirs(
|
|
|
+ path.join(dir, "session", session.value.projectID, session.value.id + ".json"),
|
|
|
+ JSON.stringify(
|
|
|
+ {
|
|
|
+ ...(raw as Record<string, unknown>),
|
|
|
+ summary: {
|
|
|
+ additions: diffs.reduce((sum, x) => sum + x.additions, 0),
|
|
|
+ deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
|
|
|
+ },
|
|
|
+ },
|
|
|
+ null,
|
|
|
+ 2,
|
|
|
+ ),
|
|
|
+ )
|
|
|
}
|
|
|
- },
|
|
|
+ }),
|
|
|
]
|
|
|
|
|
|
- const state = lazy(async () => {
|
|
|
- const dir = path.join(Global.Path.data, "storage")
|
|
|
- const migration = await Filesystem.readJson<string>(path.join(dir, "migration"))
|
|
|
- .then((x) => parseInt(x))
|
|
|
- .catch(() => 0)
|
|
|
- for (let index = migration; index < MIGRATIONS.length; index++) {
|
|
|
- log.info("running migration", { index })
|
|
|
- const migration = MIGRATIONS[index]
|
|
|
- await migration(dir).catch(() => log.error("failed to run migration", { index }))
|
|
|
- await Filesystem.write(path.join(dir, "migration"), (index + 1).toString())
|
|
|
- }
|
|
|
- return {
|
|
|
- dir,
|
|
|
- }
|
|
|
- })
|
|
|
+ export const layer = Layer.effect(
|
|
|
+ Service,
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const fs = yield* AppFileSystem.Service
|
|
|
+ const locks = yield* RcMap.make({
|
|
|
+ lookup: () => TxReentrantLock.make(),
|
|
|
+ idleTimeToLive: 0,
|
|
|
+ })
|
|
|
+ const state = yield* Effect.cached(
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const dir = path.join(Global.Path.data, "storage")
|
|
|
+ const marker = path.join(dir, "migration")
|
|
|
+ const migration = yield* fs.readFileString(marker).pipe(
|
|
|
+ Effect.map(parseMigration),
|
|
|
+ Effect.catchIf(missing, () => Effect.succeed(0)),
|
|
|
+ Effect.orElseSucceed(() => 0),
|
|
|
+ )
|
|
|
+ for (let i = migration; i < MIGRATIONS.length; i++) {
|
|
|
+ log.info("running migration", { index: i })
|
|
|
+ const step = MIGRATIONS[i]!
|
|
|
+ const exit = yield* Effect.exit(step(dir, fs))
|
|
|
+ if (Exit.isFailure(exit)) {
|
|
|
+ log.error("failed to run migration", { index: i, cause: exit.cause })
|
|
|
+ break
|
|
|
+ }
|
|
|
+ yield* fs.writeWithDirs(marker, String(i + 1))
|
|
|
+ }
|
|
|
+ return { dir }
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ const fail = (target: string): Effect.Effect<never, InstanceType<typeof NotFoundError>> =>
|
|
|
+ Effect.fail(new NotFoundError({ message: `Resource not found: ${target}` }))
|
|
|
+
|
|
|
+ const wrap = <A>(target: string, body: Effect.Effect<A, AppFileSystem.Error>) =>
|
|
|
+ body.pipe(Effect.catchIf(missing, () => fail(target)))
|
|
|
+
|
|
|
+ const writeJson = Effect.fnUntraced(function* (target: string, content: unknown) {
|
|
|
+ yield* fs.writeWithDirs(target, JSON.stringify(content, null, 2))
|
|
|
+ })
|
|
|
+
|
|
|
+ const withResolved = <A, E>(
|
|
|
+ key: string[],
|
|
|
+ fn: (target: string, rw: TxReentrantLock.TxReentrantLock) => Effect.Effect<A, E>,
|
|
|
+ ): Effect.Effect<A, E | AppFileSystem.Error> =>
|
|
|
+ Effect.scoped(
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const target = file((yield* state).dir, key)
|
|
|
+ return yield* fn(target, yield* RcMap.get(locks, target))
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ const remove: Interface["remove"] = Effect.fn("Storage.remove")(function* (key: string[]) {
|
|
|
+ yield* withResolved(key, (target, rw) =>
|
|
|
+ TxReentrantLock.withWriteLock(rw, fs.remove(target).pipe(Effect.catchIf(missing, () => Effect.void))),
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ const read: Interface["read"] = <T>(key: string[]) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const value = yield* withResolved(key, (target, rw) =>
|
|
|
+ TxReentrantLock.withReadLock(rw, wrap(target, fs.readJson(target))),
|
|
|
+ )
|
|
|
+ return value as T
|
|
|
+ })
|
|
|
+
|
|
|
+ const update: Interface["update"] = <T>(key: string[], fn: (draft: T) => void) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const value = yield* withResolved(key, (target, rw) =>
|
|
|
+ TxReentrantLock.withWriteLock(
|
|
|
+ rw,
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const content = yield* wrap(target, fs.readJson(target))
|
|
|
+ fn(content as T)
|
|
|
+ yield* writeJson(target, content)
|
|
|
+ return content
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ return value as T
|
|
|
+ })
|
|
|
+
|
|
|
+ const write: Interface["write"] = (key: string[], content: unknown) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ yield* withResolved(key, (target, rw) => TxReentrantLock.withWriteLock(rw, writeJson(target, content)))
|
|
|
+ })
|
|
|
+
|
|
|
+ const list: Interface["list"] = Effect.fn("Storage.list")(function* (prefix: string[]) {
|
|
|
+ const dir = (yield* state).dir
|
|
|
+ const cwd = path.join(dir, ...prefix)
|
|
|
+ const result = yield* fs
|
|
|
+ .glob("**/*", {
|
|
|
+ cwd,
|
|
|
+ include: "file",
|
|
|
+ })
|
|
|
+ .pipe(Effect.catch(() => Effect.succeed<string[]>([])))
|
|
|
+ return result
|
|
|
+ .map((x) => [...prefix, ...x.slice(0, -5).split(path.sep)])
|
|
|
+ .toSorted((a, b) => a.join("/").localeCompare(b.join("/")))
|
|
|
+ })
|
|
|
+
|
|
|
+ return Service.of({
|
|
|
+ remove,
|
|
|
+ read,
|
|
|
+ update,
|
|
|
+ write,
|
|
|
+ list,
|
|
|
+ })
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
|
|
+
|
|
|
+ const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
|
|
|
|
export async function remove(key: string[]) {
|
|
|
- const dir = await state().then((x) => x.dir)
|
|
|
- const target = path.join(dir, ...key) + ".json"
|
|
|
- return withErrorHandling(async () => {
|
|
|
- await fs.unlink(target).catch(() => {})
|
|
|
- })
|
|
|
+ return runPromise((svc) => svc.remove(key))
|
|
|
}
|
|
|
|
|
|
export async function read<T>(key: string[]) {
|
|
|
- const dir = await state().then((x) => x.dir)
|
|
|
- const target = path.join(dir, ...key) + ".json"
|
|
|
- return withErrorHandling(async () => {
|
|
|
- using _ = await Lock.read(target)
|
|
|
- const result = await Filesystem.readJson<T>(target)
|
|
|
- return result as T
|
|
|
- })
|
|
|
+ return runPromise((svc) => svc.read<T>(key))
|
|
|
}
|
|
|
|
|
|
export async function update<T>(key: string[], fn: (draft: T) => void) {
|
|
|
- const dir = await state().then((x) => x.dir)
|
|
|
- const target = path.join(dir, ...key) + ".json"
|
|
|
- return withErrorHandling(async () => {
|
|
|
- using _ = await Lock.write(target)
|
|
|
- const content = await Filesystem.readJson<T>(target)
|
|
|
- fn(content as T)
|
|
|
- await Filesystem.writeJson(target, content)
|
|
|
- return content
|
|
|
- })
|
|
|
+ return runPromise((svc) => svc.update<T>(key, fn))
|
|
|
}
|
|
|
|
|
|
export async function write<T>(key: string[], content: T) {
|
|
|
- const dir = await state().then((x) => x.dir)
|
|
|
- const target = path.join(dir, ...key) + ".json"
|
|
|
- return withErrorHandling(async () => {
|
|
|
- using _ = await Lock.write(target)
|
|
|
- await Filesystem.writeJson(target, content)
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
- async function withErrorHandling<T>(body: () => Promise<T>) {
|
|
|
- return body().catch((e) => {
|
|
|
- if (!(e instanceof Error)) throw e
|
|
|
- const errnoException = e as NodeJS.ErrnoException
|
|
|
- if (errnoException.code === "ENOENT") {
|
|
|
- throw new NotFoundError({ message: `Resource not found: ${errnoException.path}` })
|
|
|
- }
|
|
|
- throw e
|
|
|
- })
|
|
|
+ return runPromise((svc) => svc.write(key, content))
|
|
|
}
|
|
|
|
|
|
export async function list(prefix: string[]) {
|
|
|
- const dir = await state().then((x) => x.dir)
|
|
|
- try {
|
|
|
- const result = await Glob.scan("**/*", {
|
|
|
- cwd: path.join(dir, ...prefix),
|
|
|
- include: "file",
|
|
|
- }).then((results) => results.map((x) => [...prefix, ...x.slice(0, -5).split(path.sep)]))
|
|
|
- result.sort()
|
|
|
- return result
|
|
|
- } catch {
|
|
|
- return []
|
|
|
- }
|
|
|
+ return runPromise((svc) => svc.list(prefix))
|
|
|
}
|
|
|
}
|