chore: delete filetime module (#22999)

This commit is contained in:
Aiden Cline
2026-04-16 23:31:21 -05:00
committed by GitHub
parent 4bd5a158a5
commit 76a141090e
34 changed files with 59 additions and 766 deletions
-422
View File
@@ -1,422 +0,0 @@
import { afterEach, describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { FileTime } from "../../src/file/time"
import { Instance } from "../../src/project/instance"
import { SessionID } from "../../src/session/schema"
import { Filesystem } from "../../src/util"
import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
afterEach(async () => {
await Instance.disposeAll()
})
const it = testEffect(Layer.mergeAll(FileTime.defaultLayer, CrossSpawnSpawner.defaultLayer))
const id = SessionID.make("ses_00000000000000000000000001")
const put = (file: string, text: string) => Effect.promise(() => fs.writeFile(file, text, "utf-8"))
const touch = (file: string, time: number) =>
Effect.promise(() => {
const date = new Date(time)
return fs.utimes(file, date, date)
})
const read = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.read(id, file))
const get = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.get(id, file))
const check = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.assert(id, file))
const lock = <A>(file: string, fn: () => Effect.Effect<A>) => FileTime.Service.use((svc) => svc.withLock(file, fn))
const fail = Effect.fn("FileTimeTest.fail")(function* <A, E, R>(self: Effect.Effect<A, E, R>) {
const exit = yield* self.pipe(Effect.exit)
if (Exit.isFailure(exit)) {
const err = Cause.squash(exit.cause)
return err instanceof Error ? err : new Error(String(err))
}
throw new Error("expected file time effect to fail")
})
describe("file/time", () => {
describe("read() and get()", () => {
it.live("stores read timestamp", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
const before = yield* get(id, file)
expect(before).toBeUndefined()
yield* read(id, file)
const after = yield* get(id, file)
expect(after).toBeInstanceOf(Date)
expect(after!.getTime()).toBeGreaterThan(0)
}),
),
)
it.live("tracks separate timestamps per session", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
const one = SessionID.make("ses_00000000000000000000000002")
const two = SessionID.make("ses_00000000000000000000000003")
yield* read(one, file)
yield* read(two, file)
const first = yield* get(one, file)
const second = yield* get(two, file)
expect(first).toBeDefined()
expect(second).toBeDefined()
}),
),
)
it.live("updates timestamp on subsequent reads", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
yield* read(id, file)
const first = yield* get(id, file)
yield* read(id, file)
const second = yield* get(id, file)
expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime())
}),
),
)
it.live("isolates reads by directory", () =>
Effect.gen(function* () {
const one = yield* tmpdirScoped()
const two = yield* tmpdirScoped()
const shared = yield* tmpdirScoped()
const file = path.join(shared, "file.txt")
yield* put(file, "content")
yield* provideInstance(one)(read(id, file))
const result = yield* provideInstance(two)(get(id, file))
expect(result).toBeUndefined()
}),
)
})
describe("assert()", () => {
it.live("passes when file has not been modified", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
yield* touch(file, 1_000)
yield* read(id, file)
yield* check(id, file)
}),
),
)
it.live("throws when file was not read first", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
const err = yield* fail(check(id, file))
expect(err.message).toContain("You must read file")
}),
),
)
it.live("throws when file was modified after read", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
yield* touch(file, 1_000)
yield* read(id, file)
yield* put(file, "modified content")
yield* touch(file, 2_000)
const err = yield* fail(check(id, file))
expect(err.message).toContain("modified since it was last read")
}),
),
)
it.live("includes timestamps in error message", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
yield* touch(file, 1_000)
yield* read(id, file)
yield* put(file, "modified")
yield* touch(file, 2_000)
const err = yield* fail(check(id, file))
expect(err.message).toContain("Last modification:")
expect(err.message).toContain("Last read:")
}),
),
)
})
describe("withLock()", () => {
it.live("executes function within lock", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
let hit = false
yield* lock(file, () =>
Effect.sync(() => {
hit = true
return "result"
}),
)
expect(hit).toBe(true)
}),
),
)
it.live("returns function result", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
const result = yield* lock(file, () => Effect.succeed("success"))
expect(result).toBe("success")
}),
),
)
it.live("serializes concurrent operations on same file", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
const order: number[] = []
const hold = yield* Deferred.make<void>()
const ready = yield* Deferred.make<void>()
const one = yield* lock(file, () =>
Effect.gen(function* () {
order.push(1)
yield* Deferred.succeed(ready, void 0)
yield* Deferred.await(hold)
order.push(2)
}),
).pipe(Effect.forkScoped)
yield* Deferred.await(ready)
const two = yield* lock(file, () =>
Effect.sync(() => {
order.push(3)
order.push(4)
}),
).pipe(Effect.forkScoped)
yield* Deferred.succeed(hold, void 0)
yield* Fiber.join(one)
yield* Fiber.join(two)
expect(order).toEqual([1, 2, 3, 4])
}),
),
)
it.live("allows concurrent operations on different files", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const onefile = path.join(dir, "file1.txt")
const twofile = path.join(dir, "file2.txt")
let one = false
let two = false
const hold = yield* Deferred.make<void>()
const ready = yield* Deferred.make<void>()
const a = yield* lock(onefile, () =>
Effect.gen(function* () {
one = true
yield* Deferred.succeed(ready, void 0)
yield* Deferred.await(hold)
expect(two).toBe(true)
}),
).pipe(Effect.forkScoped)
yield* Deferred.await(ready)
const b = yield* lock(twofile, () =>
Effect.sync(() => {
two = true
}),
).pipe(Effect.forkScoped)
yield* Fiber.join(b)
yield* Deferred.succeed(hold, void 0)
yield* Fiber.join(a)
expect(one).toBe(true)
expect(two).toBe(true)
}),
),
)
it.live("releases lock even if function throws", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
const err = yield* fail(lock(file, () => Effect.die(new Error("Test error"))))
expect(err.message).toContain("Test error")
let hit = false
yield* lock(file, () =>
Effect.sync(() => {
hit = true
}),
)
expect(hit).toBe(true)
}),
),
)
})
describe("path normalization", () => {
it.live("read with forward slashes, assert with backslashes", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
yield* touch(file, 1_000)
const forward = file.replaceAll("\\", "/")
yield* read(id, forward)
yield* check(id, file)
}),
),
)
it.live("read with backslashes, assert with forward slashes", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
yield* touch(file, 1_000)
const forward = file.replaceAll("\\", "/")
yield* read(id, file)
yield* check(id, forward)
}),
),
)
it.live("get returns timestamp regardless of slash direction", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
const forward = file.replaceAll("\\", "/")
yield* read(id, forward)
const result = yield* get(id, file)
expect(result).toBeInstanceOf(Date)
}),
),
)
it.live("withLock serializes regardless of slash direction", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
const forward = file.replaceAll("\\", "/")
const order: number[] = []
const hold = yield* Deferred.make<void>()
const ready = yield* Deferred.make<void>()
const one = yield* lock(file, () =>
Effect.gen(function* () {
order.push(1)
yield* Deferred.succeed(ready, void 0)
yield* Deferred.await(hold)
order.push(2)
}),
).pipe(Effect.forkScoped)
yield* Deferred.await(ready)
const two = yield* lock(forward, () =>
Effect.sync(() => {
order.push(3)
order.push(4)
}),
).pipe(Effect.forkScoped)
yield* Deferred.succeed(hold, void 0)
yield* Fiber.join(one)
yield* Fiber.join(two)
expect(order).toEqual([1, 2, 3, 4])
}),
),
)
})
describe("stat() Filesystem.stat pattern", () => {
it.live("reads file modification time via Filesystem.stat()", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
yield* touch(file, 1_000)
yield* read(id, file)
const stat = Filesystem.stat(file)
expect(stat?.mtime).toBeInstanceOf(Date)
expect(stat!.mtime.getTime()).toBeGreaterThan(0)
yield* check(id, file)
}),
),
)
it.live("detects modification via stat mtime", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "original")
yield* touch(file, 1_000)
yield* read(id, file)
const first = Filesystem.stat(file)
yield* put(file, "modified")
yield* touch(file, 2_000)
const second = Filesystem.stat(file)
expect(second!.mtime.getTime()).toBeGreaterThan(first!.mtime.getTime())
yield* fail(check(id, file))
}),
),
)
})
})
@@ -7,7 +7,6 @@ import { Agent as AgentSvc } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { Command } from "../../src/command"
import { Config } from "../../src/config"
import { FileTime } from "../../src/file/time"
import { LSP } from "../../src/lsp"
import { MCP } from "../../src/mcp"
import { Permission } from "../../src/permission"
@@ -148,16 +147,6 @@ const lsp = Layer.succeed(
}),
)
const filetime = Layer.succeed(
FileTime.Service,
FileTime.Service.of({
read: () => Effect.void,
get: () => Effect.succeed(undefined),
assert: () => Effect.void,
withLock: (_filepath, fn) => fn(),
}),
)
const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer))
const run = SessionRunState.layer.pipe(Layer.provide(status))
const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
@@ -173,7 +162,6 @@ function makeHttp() {
Plugin.defaultLayer,
Config.defaultLayer,
ProviderSvc.defaultLayer,
filetime,
lsp,
mcp,
AppFileSystem.defaultLayer,
@@ -33,7 +33,6 @@ import { Agent as AgentSvc } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { Command } from "../../src/command"
import { Config } from "../../src/config"
import { FileTime } from "../../src/file/time"
import { LSP } from "../../src/lsp"
import { MCP } from "../../src/mcp"
import { Permission } from "../../src/permission"
@@ -102,16 +101,6 @@ const lsp = Layer.succeed(
}),
)
const filetime = Layer.succeed(
FileTime.Service,
FileTime.Service.of({
read: () => Effect.void,
get: () => Effect.succeed(undefined),
assert: () => Effect.void,
withLock: (_filepath, fn) => fn(),
}),
)
const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer))
const run = SessionRunState.layer.pipe(Layer.provide(status))
const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
@@ -128,7 +117,6 @@ function makeHttp() {
Plugin.defaultLayer,
Config.defaultLayer,
ProviderSvc.defaultLayer,
filetime,
lsp,
mcp,
AppFileSystem.defaultLayer,
-88
View File
@@ -5,7 +5,6 @@ import { Effect, Layer, ManagedRuntime } from "effect"
import { EditTool } from "../../src/tool/edit"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { FileTime } from "../../src/file/time"
import { LSP } from "../../src/lsp"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Format } from "../../src/format"
@@ -38,7 +37,6 @@ async function touch(file: string, time: number) {
const runtime = ManagedRuntime.make(
Layer.mergeAll(
LSP.defaultLayer,
FileTime.defaultLayer,
AppFileSystem.defaultLayer,
Format.defaultLayer,
Bus.layer,
@@ -59,9 +57,6 @@ const resolve = () =>
}),
)
const readFileTime = (sessionID: SessionID, filepath: string) =>
runtime.runPromise(FileTime.Service.use((ft) => ft.read(sessionID, filepath)))
const subscribeBus = <D extends BusEvent.Definition>(def: D, callback: () => unknown) =>
runtime.runPromise(Bus.Service.use((bus) => bus.subscribeCallback(def, callback)))
@@ -173,8 +168,6 @@ describe("tool.edit", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await readFileTime(ctx.sessionID, filepath)
const edit = await resolve()
const result = await Effect.runPromise(
edit.execute(
@@ -202,8 +195,6 @@ describe("tool.edit", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await readFileTime(ctx.sessionID, filepath)
const edit = await resolve()
await expect(
Effect.runPromise(
@@ -254,8 +245,6 @@ describe("tool.edit", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await readFileTime(ctx.sessionID, filepath)
const edit = await resolve()
await expect(
Effect.runPromise(
@@ -273,65 +262,6 @@ describe("tool.edit", () => {
})
})
test("throws error when file was not read first (FileTime)", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await resolve()
await expect(
Effect.runPromise(
edit.execute(
{
filePath: filepath,
oldString: "content",
newString: "modified",
},
ctx,
),
),
).rejects.toThrow("You must read file")
},
})
})
test("throws error when file has been modified since read", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "original content", "utf-8")
await touch(filepath, 1_000)
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Read first
await readFileTime(ctx.sessionID, filepath)
// Simulate external modification
await fs.writeFile(filepath, "modified externally", "utf-8")
await touch(filepath, 2_000)
// Try to edit with the new content
const edit = await resolve()
await expect(
Effect.runPromise(
edit.execute(
{
filePath: filepath,
oldString: "modified externally",
newString: "edited",
},
ctx,
),
),
).rejects.toThrow("modified since it was last read")
},
})
})
test("replaces all occurrences with replaceAll option", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
@@ -340,8 +270,6 @@ describe("tool.edit", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await readFileTime(ctx.sessionID, filepath)
const edit = await resolve()
await Effect.runPromise(
edit.execute(
@@ -369,8 +297,6 @@ describe("tool.edit", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await readFileTime(ctx.sessionID, filepath)
const { FileWatcher } = await import("../../src/file/watcher")
const updated = await onceBus(FileWatcher.Event.Updated)
@@ -406,8 +332,6 @@ describe("tool.edit", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await readFileTime(ctx.sessionID, filepath)
const edit = await resolve()
await Effect.runPromise(
edit.execute(
@@ -434,8 +358,6 @@ describe("tool.edit", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await readFileTime(ctx.sessionID, filepath)
const edit = await resolve()
await Effect.runPromise(
edit.execute(
@@ -487,8 +409,6 @@ describe("tool.edit", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await readFileTime(ctx.sessionID, dirpath)
const edit = await resolve()
await expect(
Effect.runPromise(
@@ -514,8 +434,6 @@ describe("tool.edit", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await readFileTime(ctx.sessionID, filepath)
const edit = await resolve()
const result = await Effect.runPromise(
edit.execute(
@@ -587,7 +505,6 @@ describe("tool.edit", () => {
fn: async () => {
const edit = await resolve()
const filePath = path.join(tmp.path, "test.txt")
await readFileTime(ctx.sessionID, filePath)
await Effect.runPromise(
edit.execute(
{
@@ -730,8 +647,6 @@ describe("tool.edit", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await readFileTime(ctx.sessionID, filepath)
const edit = await resolve()
// Two concurrent edits
@@ -746,9 +661,6 @@ describe("tool.edit", () => {
),
)
// Need to read again since FileTime tracks per-session
await readFileTime(ctx.sessionID, filepath)
const promise2 = Effect.runPromise(
edit.execute(
{
-2
View File
@@ -4,7 +4,6 @@ import path from "path"
import { Agent } from "../../src/agent/agent"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { FileTime } from "../../src/file/time"
import { LSP } from "../../src/lsp"
import { Permission } from "../../src/permission"
import { Instance } from "../../src/project/instance"
@@ -40,7 +39,6 @@ const it = testEffect(
Agent.defaultLayer,
AppFileSystem.defaultLayer,
CrossSpawnSpawner.defaultLayer,
FileTime.defaultLayer,
Instruction.defaultLayer,
LSP.defaultLayer,
Truncate.defaultLayer,
-13
View File
@@ -6,7 +6,6 @@ import { WriteTool } from "../../src/tool/write"
import { Instance } from "../../src/project/instance"
import { LSP } from "../../src/lsp"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { FileTime } from "../../src/file/time"
import { Bus } from "../../src/bus"
import { Format } from "../../src/format"
import { Truncate } from "../../src/tool"
@@ -36,7 +35,6 @@ const it = testEffect(
Layer.mergeAll(
LSP.defaultLayer,
AppFileSystem.defaultLayer,
FileTime.defaultLayer,
Bus.layer,
Format.defaultLayer,
CrossSpawnSpawner.defaultLayer,
@@ -58,11 +56,6 @@ const run = Effect.fn("WriteToolTest.run")(function* (
return yield* tool.execute(args, next)
})
const markRead = Effect.fn("WriteToolTest.markRead")(function* (sessionID: string, filepath: string) {
const ft = yield* FileTime.Service
yield* ft.read(sessionID as any, filepath)
})
describe("tool.write", () => {
describe("new file creation", () => {
it.live("writes content to new file", () =>
@@ -110,8 +103,6 @@ describe("tool.write", () => {
Effect.gen(function* () {
const filepath = path.join(dir, "existing.txt")
yield* Effect.promise(() => fs.writeFile(filepath, "old content", "utf-8"))
yield* markRead(ctx.sessionID, filepath)
const result = yield* run({ filePath: filepath, content: "new content" })
expect(result.output).toContain("Wrote file successfully")
@@ -128,8 +119,6 @@ describe("tool.write", () => {
Effect.gen(function* () {
const filepath = path.join(dir, "file.txt")
yield* Effect.promise(() => fs.writeFile(filepath, "old", "utf-8"))
yield* markRead(ctx.sessionID, filepath)
const result = yield* run({ filePath: filepath, content: "new" })
expect(result.metadata).toHaveProperty("filepath", filepath)
@@ -231,8 +220,6 @@ describe("tool.write", () => {
const readonlyPath = path.join(dir, "readonly.txt")
yield* Effect.promise(() => fs.writeFile(readonlyPath, "test", "utf-8"))
yield* Effect.promise(() => fs.chmod(readonlyPath, 0o444))
yield* markRead(ctx.sessionID, readonlyPath)
const exit = yield* run({ filePath: readonlyPath, content: "new content" }).pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
}),