import { DateTime, Effect, Layer, Option, Semaphore, Context } from "effect" import { InstanceState } from "@/effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Flag } from "@/flag/flag" import type { SessionID } from "@/session/schema" import { Log } from "../util/log" export namespace FileTime { const log = Log.create({ service: "file.time" }) export type Stamp = { readonly read: Date readonly mtime: number | undefined readonly size: number | undefined } const session = (reads: Map>, sessionID: SessionID) => { const value = reads.get(sessionID) if (value) return value const next = new Map() reads.set(sessionID, next) return next } interface State { reads: Map> locks: Map } export interface Interface { readonly read: (sessionID: SessionID, file: string) => Effect.Effect readonly get: (sessionID: SessionID, file: string) => Effect.Effect readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect readonly withLock: (filepath: string, fn: () => Effect.Effect) => Effect.Effect } export class Service extends Context.Service()("@opencode/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.void)) return { read: yield* DateTime.nowAsDate, mtime: info ? Option.getOrUndefined(info.mtime)?.getTime() : undefined, size: info ? Number(info.size) : undefined, } }) const state = yield* InstanceState.make( Effect.fn("FileTime.state")(() => Effect.succeed({ reads: new Map>(), locks: new Map(), }), ), ) const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) { filepath = AppFileSystem.normalizePath(filepath) const locks = (yield* InstanceState.get(state)).locks const lock = locks.get(filepath) if (lock) return lock const next = Semaphore.makeUnsafe(1) locks.set(filepath, next) return next }) const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) { file = AppFileSystem.normalizePath(file) const reads = (yield* InstanceState.get(state)).reads log.info("read", { sessionID, file }) session(reads, sessionID).set(file, yield* stamp(file)) }) const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) { file = AppFileSystem.normalizePath(file) const reads = (yield* InstanceState.get(state)).reads return reads.get(sessionID)?.get(file)?.read }) const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) { if (disableCheck) return filepath = AppFileSystem.normalizePath(filepath) const reads = (yield* InstanceState.get(state)).reads const time = reads.get(sessionID)?.get(filepath) 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.size !== time.size if (!changed) return throw new Error( `File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`, ) }) const withLock = Effect.fn("FileTime.withLock")(function* (filepath: string, fn: () => Effect.Effect) { return yield* fn().pipe((yield* getLock(filepath)).withPermits(1)) }) return Service.of({ read, get, assert, withLock }) }), ).pipe(Layer.orDie) export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) }