From 105606e389be5bf266ec352664ecd9c648301033 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 17 Mar 2026 19:53:55 -0400 Subject: [PATCH] refactor(truncation): centralize output and effect test helpers --- packages/opencode/src/permission/evaluate.ts | 12 + packages/opencode/src/permission/next.ts | 3 +- packages/opencode/src/permission/service.ts | 10 +- packages/opencode/src/project/bootstrap.ts | 2 - packages/opencode/src/snapshot/index.ts | 794 +++++++----------- .../opencode/src/tool/truncate-service.ts | 85 +- packages/opencode/src/tool/truncation.ts | 82 +- packages/opencode/test/account/repo.test.ts | 38 +- .../opencode/test/account/service.test.ts | 32 +- packages/opencode/test/fixture/effect.ts | 7 - packages/opencode/test/lib/effect.ts | 37 + packages/opencode/test/lib/filesystem.ts | 10 + .../opencode/test/tool/truncation.test.ts | 48 +- 13 files changed, 523 insertions(+), 637 deletions(-) create mode 100644 packages/opencode/src/permission/evaluate.ts delete mode 100644 packages/opencode/test/fixture/effect.ts create mode 100644 packages/opencode/test/lib/effect.ts create mode 100644 packages/opencode/test/lib/filesystem.ts diff --git a/packages/opencode/src/permission/evaluate.ts b/packages/opencode/src/permission/evaluate.ts new file mode 100644 index 0000000000..f891a14c60 --- /dev/null +++ b/packages/opencode/src/permission/evaluate.ts @@ -0,0 +1,12 @@ +import { Log } from "@/util/log" +import { Wildcard } from "@/util/wildcard" +import type { Rule, Ruleset } from "./service" + +const log = Log.create({ service: "permission" }) + +export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { + const rules = rulesets.flat() + log.info("evaluate", { permission, pattern, ruleset: rules }) + const match = rules.findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) + return match ?? { action: "ask", permission, pattern: "*" } +} diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 6a65a6f2e9..426682ebca 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -3,6 +3,7 @@ import { Config } from "@/config/config" import { fn } from "@/util/fn" import { Wildcard } from "@/util/wildcard" import os from "os" +import { evaluate as run } from "./evaluate" import * as S from "./service" export namespace PermissionNext { @@ -66,7 +67,7 @@ export namespace PermissionNext { } export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { - return S.evaluate(permission, pattern, ...rulesets) + return run(permission, pattern, ...rulesets) } const EDIT_TOOLS = ["edit", "write", "patch", "multiedit"] diff --git a/packages/opencode/src/permission/service.ts b/packages/opencode/src/permission/service.ts index f20b19acf3..e8f98771d3 100644 --- a/packages/opencode/src/permission/service.ts +++ b/packages/opencode/src/permission/service.ts @@ -9,6 +9,7 @@ import { Log } from "@/util/log" import { Wildcard } from "@/util/wildcard" import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" import z from "zod" +import { evaluate } from "./evaluate" import { PermissionID } from "./schema" const log = Log.create({ service: "permission" }) @@ -240,12 +241,3 @@ export class PermissionService extends ServiceMap.Service Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern), - ) - return match ?? { action: "ask", permission, pattern: "*" } -} diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 206cfcd0ea..40a4ce9ccd 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -10,7 +10,6 @@ import { Instance } from "./instance" import { VcsService } from "./vcs" import { Log } from "@/util/log" import { ShareNext } from "@/share/share-next" -import { Snapshot } from "../snapshot" import { runPromiseInstance } from "@/effect/runtime" export async function InstanceBootstrap() { @@ -22,7 +21,6 @@ export async function InstanceBootstrap() { await runPromiseInstance(FileWatcherService.use((service) => service.init())) File.init() await runPromiseInstance(VcsService.use((s) => s.init())) - Snapshot.init() Bus.subscribe(Command.Event.Executed, async (payload) => { if (payload.properties.name === Command.Default.INIT) { diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index ccba830b81..dc4203267f 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -1,516 +1,374 @@ -import { - NodeChildProcessSpawner, - NodeFileSystem, - NodePath, -} from "@effect/platform-node"; -import { - Cause, - Duration, - Effect, - FileSystem, - Layer, - Schedule, - ServiceMap, - Stream, -} from "effect"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import path from "path"; -import z from "zod"; -import { InstanceContext } from "@/effect/instance-context"; -import { runPromiseInstance } from "@/effect/runtime"; -import { Config } from "../config/config"; -import { Global } from "../global"; -import { Log } from "../util/log"; +import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" +import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap, Stream } from "effect" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import path from "path" +import z from "zod" +import { InstanceContext } from "@/effect/instance-context" +import { runPromiseInstance } from "@/effect/runtime" +import { Config } from "../config/config" +import { Global } from "../global" +import { Log } from "../util/log" -const log = Log.create({ service: "snapshot" }); -const PRUNE = "7.days"; +const log = Log.create({ service: "snapshot" }) +const PRUNE = "7.days" // Common git config flags shared across snapshot operations -const GIT_CORE = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]; -const GIT_CFG = ["-c", "core.autocrlf=false", ...GIT_CORE]; -const GIT_CFG_QUOTE = [...GIT_CFG, "-c", "core.quotepath=false"]; +const GIT_CORE = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"] +const GIT_CFG = ["-c", "core.autocrlf=false", ...GIT_CORE] +const GIT_CFG_QUOTE = [...GIT_CFG, "-c", "core.quotepath=false"] interface GitResult { - readonly code: ChildProcessSpawner.ExitCode; - readonly text: string; - readonly stderr: string; + readonly code: ChildProcessSpawner.ExitCode + readonly text: string + readonly stderr: string } export namespace Snapshot { - export const Patch = z.object({ - hash: z.string(), - files: z.string().array(), - }); - export type Patch = z.infer; + export const Patch = z.object({ + hash: z.string(), + files: z.string().array(), + }) + export type Patch = z.infer - export const FileDiff = z - .object({ - file: z.string(), - before: z.string(), - after: z.string(), - additions: z.number(), - deletions: z.number(), - status: z.enum(["added", "deleted", "modified"]).optional(), - }) - .meta({ - ref: "FileDiff", - }); - export type FileDiff = z.infer; + export const FileDiff = z + .object({ + file: z.string(), + before: z.string(), + after: z.string(), + additions: z.number(), + deletions: z.number(), + status: z.enum(["added", "deleted", "modified"]).optional(), + }) + .meta({ + ref: "FileDiff", + }) + export type FileDiff = z.infer - // Promise facade — existing callers use these - export function init() { - void runPromiseInstance(SnapshotService.use((s) => s.init())); - } + export async function cleanup() { + return runPromiseInstance(SnapshotService.use((s) => s.cleanup())) + } - export async function cleanup() { - return runPromiseInstance(SnapshotService.use((s) => s.cleanup())); - } + export async function track() { + return runPromiseInstance(SnapshotService.use((s) => s.track())) + } - export async function track() { - return runPromiseInstance(SnapshotService.use((s) => s.track())); - } + export async function patch(hash: string) { + return runPromiseInstance(SnapshotService.use((s) => s.patch(hash))) + } - export async function patch(hash: string) { - return runPromiseInstance(SnapshotService.use((s) => s.patch(hash))); - } + export async function restore(snapshot: string) { + return runPromiseInstance(SnapshotService.use((s) => s.restore(snapshot))) + } - export async function restore(snapshot: string) { - return runPromiseInstance(SnapshotService.use((s) => s.restore(snapshot))); - } + export async function revert(patches: Patch[]) { + return runPromiseInstance(SnapshotService.use((s) => s.revert(patches))) + } - export async function revert(patches: Patch[]) { - return runPromiseInstance(SnapshotService.use((s) => s.revert(patches))); - } + export async function diff(hash: string) { + return runPromiseInstance(SnapshotService.use((s) => s.diff(hash))) + } - export async function diff(hash: string) { - return runPromiseInstance(SnapshotService.use((s) => s.diff(hash))); - } - - export async function diffFull(from: string, to: string) { - return runPromiseInstance(SnapshotService.use((s) => s.diffFull(from, to))); - } + export async function diffFull(from: string, to: string) { + return runPromiseInstance(SnapshotService.use((s) => s.diffFull(from, to))) + } } export namespace SnapshotService { - export interface Service { - readonly init: () => Effect.Effect; - readonly cleanup: () => Effect.Effect; - readonly track: () => Effect.Effect; - readonly patch: (hash: string) => Effect.Effect; - readonly restore: (snapshot: string) => Effect.Effect; - readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect; - readonly diff: (hash: string) => Effect.Effect; - readonly diffFull: ( - from: string, - to: string, - ) => Effect.Effect; - } + export interface Service { + readonly cleanup: () => Effect.Effect + readonly track: () => Effect.Effect + readonly patch: (hash: string) => Effect.Effect + readonly restore: (snapshot: string) => Effect.Effect + readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect + readonly diff: (hash: string) => Effect.Effect + readonly diffFull: (from: string, to: string) => Effect.Effect + } } -export class SnapshotService extends ServiceMap.Service< - SnapshotService, - SnapshotService.Service ->()("@opencode/Snapshot") { - static readonly layer = Layer.effect( - SnapshotService, - Effect.gen(function* () { - const ctx = yield* InstanceContext; - const fileSystem = yield* FileSystem.FileSystem; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const { directory, worktree, project } = ctx; - const isGit = project.vcs === "git"; - const snapshotGit = path.join(Global.Path.data, "snapshot", project.id); +export class SnapshotService extends ServiceMap.Service()( + "@opencode/Snapshot", +) { + static readonly layer = Layer.effect( + SnapshotService, + Effect.gen(function* () { + const ctx = yield* InstanceContext + const fileSystem = yield* FileSystem.FileSystem + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const { directory, worktree, project } = ctx + const isGit = project.vcs === "git" + const snapshotGit = path.join(Global.Path.data, "snapshot", project.id) - const gitArgs = (cmd: string[]) => [ - "--git-dir", - snapshotGit, - "--work-tree", - worktree, - ...cmd, - ]; + const gitArgs = (cmd: string[]) => ["--git-dir", snapshotGit, "--work-tree", worktree, ...cmd] - // Run git with nothrow semantics — always returns a result, never fails - const git = ( - args: string[], - opts?: { cwd?: string; env?: Record }, - ): Effect.Effect => - Effect.gen(function* () { - const command = ChildProcess.make("git", args, { - cwd: opts?.cwd, - env: opts?.env, - extendEnv: true, - }); - const handle = yield* spawner.spawn(command); - const [text, stderr] = yield* Effect.all( - [ - Stream.mkString(Stream.decodeText(handle.stdout)), - Stream.mkString(Stream.decodeText(handle.stderr)), - ], - { concurrency: 2 }, - ); - const code = yield* handle.exitCode; - return { code, text, stderr }; - }).pipe( - Effect.scoped, - Effect.catch((err) => - Effect.succeed({ - code: ChildProcessSpawner.ExitCode(1), - text: "", - stderr: String(err), - }), - ), - ); + // Run git with nothrow semantics — always returns a result, never fails + const git = Effect.fnUntraced( + function* (args: string[], opts?: { cwd?: string; env?: Record }) { + const command = ChildProcess.make("git", args, { + cwd: opts?.cwd, + env: opts?.env, + extendEnv: true, + }) + const handle = yield* spawner.spawn(command) + const [text, stderr] = yield* Effect.all( + [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], + { concurrency: 2 }, + ) + const code = yield* handle.exitCode + return { code, text, stderr } + }, + Effect.scoped, + Effect.catch((err) => + Effect.succeed({ + code: ChildProcessSpawner.ExitCode(1), + text: "", + stderr: String(err), + }), + ), + ) - // FileSystem helpers — orDie converts PlatformError to defects - const exists = (p: string) => fileSystem.exists(p).pipe(Effect.orDie); - const mkdir = (p: string) => - fileSystem.makeDirectory(p, { recursive: true }).pipe(Effect.orDie); - const writeFile = (p: string, content: string) => - fileSystem.writeFileString(p, content).pipe(Effect.orDie); - const readFile = (p: string) => - fileSystem - .readFileString(p) - .pipe(Effect.catch(() => Effect.succeed(""))); - const removeFile = (p: string) => - fileSystem.remove(p).pipe(Effect.catch(() => Effect.void)); + // FileSystem helpers — orDie converts PlatformError to defects + const exists = (p: string) => fileSystem.exists(p).pipe(Effect.orDie) + const mkdir = (p: string) => fileSystem.makeDirectory(p, { recursive: true }).pipe(Effect.orDie) + const writeFile = (p: string, content: string) => fileSystem.writeFileString(p, content).pipe(Effect.orDie) + const readFile = (p: string) => fileSystem.readFileString(p).pipe(Effect.catch(() => Effect.succeed(""))) + const removeFile = (p: string) => fileSystem.remove(p).pipe(Effect.catch(() => Effect.void)) - // --- internal Effect helpers --- + // --- internal Effect helpers --- - const isEnabled = Effect.gen(function* () { - if (!isGit) return false; - const cfg = yield* Effect.promise(() => Config.get()); - return cfg.snapshot !== false; - }); + const isEnabled = Effect.gen(function* () { + if (!isGit) return false + const cfg = yield* Effect.promise(() => Config.get()) + return cfg.snapshot !== false + }) - const excludesPath = Effect.gen(function* () { - const result = yield* git( - ["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], - { - cwd: worktree, - }, - ); - const file = result.text.trim(); - if (!file) return undefined; - if (!(yield* exists(file))) return undefined; - return file; - }); + const excludesPath = Effect.gen(function* () { + const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], { + cwd: worktree, + }) + const file = result.text.trim() + if (!file) return undefined + if (!(yield* exists(file))) return undefined + return file + }) - const syncExclude = Effect.gen(function* () { - const file = yield* excludesPath; - const target = path.join(snapshotGit, "info", "exclude"); - yield* mkdir(path.join(snapshotGit, "info")); - if (!file) { - yield* writeFile(target, ""); - return; - } - const text = yield* readFile(file); - yield* writeFile(target, text); - }); + const syncExclude = Effect.gen(function* () { + const file = yield* excludesPath + const target = path.join(snapshotGit, "info", "exclude") + yield* mkdir(path.join(snapshotGit, "info")) + if (!file) { + yield* writeFile(target, "") + return + } + const text = yield* readFile(file) + yield* writeFile(target, text) + }) - const add = Effect.gen(function* () { - yield* syncExclude; - yield* git([...GIT_CFG, ...gitArgs(["add", "."])], { cwd: directory }); - }); + const add = Effect.gen(function* () { + yield* syncExclude + yield* git([...GIT_CFG, ...gitArgs(["add", "."])], { cwd: directory }) + }) - // --- service methods --- + // --- service methods --- - const cleanup = Effect.fn("SnapshotService.cleanup")(function* () { - if (!(yield* isEnabled)) return; - if (!(yield* exists(snapshotGit))) return; - const result = yield* git(gitArgs(["gc", `--prune=${PRUNE}`]), { - cwd: directory, - }); - if (result.code !== 0) { - log.warn("cleanup failed", { - exitCode: result.code, - stderr: result.stderr, - }); - return; - } - log.info("cleanup", { prune: PRUNE }); - }); + const cleanup = Effect.fn("SnapshotService.cleanup")(function* () { + if (!(yield* isEnabled)) return + if (!(yield* exists(snapshotGit))) return + const result = yield* git(gitArgs(["gc", `--prune=${PRUNE}`]), { + cwd: directory, + }) + if (result.code !== 0) { + log.warn("cleanup failed", { + exitCode: result.code, + stderr: result.stderr, + }) + return + } + log.info("cleanup", { prune: PRUNE }) + }) - const track = Effect.fn("SnapshotService.track")(function* () { - if (!(yield* isEnabled)) return undefined; - const existed = yield* exists(snapshotGit); - yield* mkdir(snapshotGit); - if (!existed) { - yield* git(["init"], { - env: { GIT_DIR: snapshotGit, GIT_WORK_TREE: worktree }, - }); - yield* git([ - "--git-dir", - snapshotGit, - "config", - "core.autocrlf", - "false", - ]); - yield* git([ - "--git-dir", - snapshotGit, - "config", - "core.longpaths", - "true", - ]); - yield* git([ - "--git-dir", - snapshotGit, - "config", - "core.symlinks", - "true", - ]); - yield* git([ - "--git-dir", - snapshotGit, - "config", - "core.fsmonitor", - "false", - ]); - log.info("initialized"); - } - yield* add; - const result = yield* git(gitArgs(["write-tree"]), { cwd: directory }); - const hash = result.text.trim(); - log.info("tracking", { hash, cwd: directory, git: snapshotGit }); - return hash; - }); + const track = Effect.fn("SnapshotService.track")(function* () { + if (!(yield* isEnabled)) return undefined + const existed = yield* exists(snapshotGit) + yield* mkdir(snapshotGit) + if (!existed) { + yield* git(["init"], { + env: { GIT_DIR: snapshotGit, GIT_WORK_TREE: worktree }, + }) + yield* git(["--git-dir", snapshotGit, "config", "core.autocrlf", "false"]) + yield* git(["--git-dir", snapshotGit, "config", "core.longpaths", "true"]) + yield* git(["--git-dir", snapshotGit, "config", "core.symlinks", "true"]) + yield* git(["--git-dir", snapshotGit, "config", "core.fsmonitor", "false"]) + log.info("initialized") + } + yield* add + const result = yield* git(gitArgs(["write-tree"]), { cwd: directory }) + const hash = result.text.trim() + log.info("tracking", { hash, cwd: directory, git: snapshotGit }) + return hash + }) - const patch = Effect.fn("SnapshotService.patch")(function* ( - hash: string, - ) { - yield* add; - const result = yield* git( - [ - ...GIT_CFG_QUOTE, - ...gitArgs([ - "diff", - "--no-ext-diff", - "--name-only", - hash, - "--", - ".", - ]), - ], - { cwd: directory }, - ); + const patch = Effect.fn("SnapshotService.patch")(function* (hash: string) { + yield* add + const result = yield* git( + [...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])], + { cwd: directory }, + ) - if (result.code !== 0) { - log.warn("failed to get diff", { hash, exitCode: result.code }); - return { hash, files: [] } as Snapshot.Patch; - } + if (result.code !== 0) { + log.warn("failed to get diff", { hash, exitCode: result.code }) + return { hash, files: [] } + } - return { - hash, - files: result.text - .trim() - .split("\n") - .map((x: string) => x.trim()) - .filter(Boolean) - .map((x: string) => path.join(worktree, x).replaceAll("\\", "/")), - } as Snapshot.Patch; - }); + const files = result.text + .trim() + .split("\n") + .map((x: string) => x.trim()) + .filter(Boolean) + .map((x: string) => path.join(worktree, x).replaceAll("\\", "/")) - const restore = Effect.fn("SnapshotService.restore")(function* ( - snapshot: string, - ) { - log.info("restore", { commit: snapshot }); - const result = yield* git( - [...GIT_CORE, ...gitArgs(["read-tree", snapshot])], - { cwd: worktree }, - ); - if (result.code === 0) { - const checkout = yield* git( - [...GIT_CORE, ...gitArgs(["checkout-index", "-a", "-f"])], - { cwd: worktree }, - ); - if (checkout.code === 0) return; - log.error("failed to restore snapshot", { - snapshot, - exitCode: checkout.code, - stderr: checkout.stderr, - }); - return; - } - log.error("failed to restore snapshot", { - snapshot, - exitCode: result.code, - stderr: result.stderr, - }); - }); + return { hash, files } + }) - const revert = Effect.fn("SnapshotService.revert")(function* ( - patches: Snapshot.Patch[], - ) { - const seen = new Set(); - for (const item of patches) { - for (const file of item.files) { - if (seen.has(file)) continue; - log.info("reverting", { file, hash: item.hash }); - const result = yield* git( - [...GIT_CORE, ...gitArgs(["checkout", item.hash, "--", file])], - { - cwd: worktree, - }, - ); - if (result.code !== 0) { - const relativePath = path.relative(worktree, file); - const checkTree = yield* git( - [ - ...GIT_CORE, - ...gitArgs(["ls-tree", item.hash, "--", relativePath]), - ], - { - cwd: worktree, - }, - ); - if (checkTree.code === 0 && checkTree.text.trim()) { - log.info( - "file existed in snapshot but checkout failed, keeping", - { file }, - ); - } else { - log.info("file did not exist in snapshot, deleting", { file }); - yield* removeFile(file); - } - } - seen.add(file); - } - } - }); + const restore = Effect.fn("SnapshotService.restore")(function* (snapshot: string) { + log.info("restore", { commit: snapshot }) + const result = yield* git([...GIT_CORE, ...gitArgs(["read-tree", snapshot])], { cwd: worktree }) + if (result.code === 0) { + const checkout = yield* git([...GIT_CORE, ...gitArgs(["checkout-index", "-a", "-f"])], { cwd: worktree }) + if (checkout.code === 0) return + log.error("failed to restore snapshot", { + snapshot, + exitCode: checkout.code, + stderr: checkout.stderr, + }) + return + } + log.error("failed to restore snapshot", { + snapshot, + exitCode: result.code, + stderr: result.stderr, + }) + }) - const diff = Effect.fn("SnapshotService.diff")(function* (hash: string) { - yield* add; - const result = yield* git( - [ - ...GIT_CFG_QUOTE, - ...gitArgs(["diff", "--no-ext-diff", hash, "--", "."]), - ], - { - cwd: worktree, - }, - ); + const revert = Effect.fn("SnapshotService.revert")(function* (patches: Snapshot.Patch[]) { + const seen = new Set() + for (const item of patches) { + for (const file of item.files) { + if (seen.has(file)) continue + log.info("reverting", { file, hash: item.hash }) + const result = yield* git([...GIT_CORE, ...gitArgs(["checkout", item.hash, "--", file])], { + cwd: worktree, + }) + if (result.code !== 0) { + const relativePath = path.relative(worktree, file) + const checkTree = yield* git([...GIT_CORE, ...gitArgs(["ls-tree", item.hash, "--", relativePath])], { + cwd: worktree, + }) + if (checkTree.code === 0 && checkTree.text.trim()) { + log.info("file existed in snapshot but checkout failed, keeping", { file }) + } else { + log.info("file did not exist in snapshot, deleting", { file }) + yield* removeFile(file) + } + } + seen.add(file) + } + } + }) - if (result.code !== 0) { - log.warn("failed to get diff", { - hash, - exitCode: result.code, - stderr: result.stderr, - }); - return ""; - } + const diff = Effect.fn("SnapshotService.diff")(function* (hash: string) { + yield* add + const result = yield* git([...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", hash, "--", "."])], { + cwd: worktree, + }) - return result.text.trim(); - }); + if (result.code !== 0) { + log.warn("failed to get diff", { + hash, + exitCode: result.code, + stderr: result.stderr, + }) + return "" + } - const diffFull = Effect.fn("SnapshotService.diffFull")(function* ( - from: string, - to: string, - ) { - const result: Snapshot.FileDiff[] = []; - const status = new Map(); + return result.text.trim() + }) - const statuses = yield* git( - [ - ...GIT_CFG_QUOTE, - ...gitArgs([ - "diff", - "--no-ext-diff", - "--name-status", - "--no-renames", - from, - to, - "--", - ".", - ]), - ], - { cwd: directory }, - ); + const diffFull = Effect.fn("SnapshotService.diffFull")(function* (from: string, to: string) { + const result: Snapshot.FileDiff[] = [] + const status = new Map() - for (const line of statuses.text.trim().split("\n")) { - if (!line) continue; - const [code, file] = line.split("\t"); - if (!code || !file) continue; - const kind = code.startsWith("A") - ? "added" - : code.startsWith("D") - ? "deleted" - : "modified"; - status.set(file, kind); - } + const statuses = yield* git( + [ + ...GIT_CFG_QUOTE, + ...gitArgs(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]), + ], + { cwd: directory }, + ) - const numstat = yield* git( - [ - ...GIT_CFG_QUOTE, - ...gitArgs([ - "diff", - "--no-ext-diff", - "--no-renames", - "--numstat", - from, - to, - "--", - ".", - ]), - ], - { cwd: directory }, - ); + for (const line of statuses.text.trim().split("\n")) { + if (!line) continue + const [code, file] = line.split("\t") + if (!code || !file) continue + const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified" + status.set(file, kind) + } - for (const line of numstat.text.trim().split("\n")) { - if (!line) continue; - const [additions, deletions, file] = line.split("\t"); - const isBinaryFile = additions === "-" && deletions === "-"; - const [before, after] = isBinaryFile - ? ["", ""] - : yield* Effect.all( - [ - git([ - ...GIT_CFG, - ...gitArgs(["show", `${from}:${file}`]), - ]).pipe(Effect.map((r) => r.text)), - git([...GIT_CFG, ...gitArgs(["show", `${to}:${file}`])]).pipe( - Effect.map((r) => r.text), - ), - ], - { concurrency: 2 }, - ); - const added = isBinaryFile ? 0 : parseInt(additions!); - const deleted = isBinaryFile ? 0 : parseInt(deletions!); - result.push({ - file: file!, - before, - after, - additions: Number.isFinite(added) ? added : 0, - deletions: Number.isFinite(deleted) ? deleted : 0, - status: status.get(file!) ?? "modified", - }); - } - return result; - }); + const numstat = yield* git( + [...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])], + { cwd: directory }, + ) - // Start hourly cleanup fiber — scoped to instance lifetime - yield* cleanup().pipe( - Effect.catchCause((cause) => { - log.error("cleanup loop failed", { cause: Cause.pretty(cause) }); - return Effect.void; - }), - Effect.repeat(Schedule.spaced(Duration.hours(1))), - Effect.forkScoped, - ); + for (const line of numstat.text.trim().split("\n")) { + if (!line) continue + const [additions, deletions, file] = line.split("\t") + const isBinaryFile = additions === "-" && deletions === "-" + const [before, after] = isBinaryFile + ? ["", ""] + : yield* Effect.all( + [ + git([...GIT_CFG, ...gitArgs(["show", `${from}:${file}`])]).pipe(Effect.map((r) => r.text)), + git([...GIT_CFG, ...gitArgs(["show", `${to}:${file}`])]).pipe(Effect.map((r) => r.text)), + ], + { concurrency: 2 }, + ) + const added = isBinaryFile ? 0 : parseInt(additions!) + const deleted = isBinaryFile ? 0 : parseInt(deletions!) + result.push({ + file: file!, + before, + after, + additions: Number.isFinite(added) ? added : 0, + deletions: Number.isFinite(deleted) ? deleted : 0, + status: status.get(file!) ?? "modified", + }) + } + return result + }) - return SnapshotService.of({ - init: Effect.fn("SnapshotService.init")(function* () {}), - cleanup, - track, - patch, - restore, - revert, - diff, - diffFull, - }); - }), - ).pipe( - Layer.provide(NodeChildProcessSpawner.layer), - Layer.provide(NodeFileSystem.layer), - Layer.provide(NodePath.layer), - ); + // Start delayed hourly cleanup fiber — scoped to instance lifetime + yield* cleanup().pipe( + Effect.catchCause((cause) => { + log.error("cleanup loop failed", { cause: Cause.pretty(cause) }) + return Effect.void + }), + Effect.repeat(Schedule.spaced(Duration.hours(1))), + Effect.delay(Duration.minutes(1)), + Effect.forkScoped, + ) + + return SnapshotService.of({ + cleanup, + track, + patch, + restore, + revert, + diff, + diffFull, + }) + }), + ).pipe( + Layer.provide(NodeChildProcessSpawner.layer), + Layer.provide(NodeFileSystem.layer), + Layer.provide(NodePath.layer), + ) } diff --git a/packages/opencode/src/tool/truncate-service.ts b/packages/opencode/src/tool/truncate-service.ts index cec562eea5..eee9172a08 100644 --- a/packages/opencode/src/tool/truncate-service.ts +++ b/packages/opencode/src/tool/truncate-service.ts @@ -2,15 +2,35 @@ import path from "path" import { Log } from "../util/log" import { TRUNCATION_DIR } from "./truncation-dir" import { Identifier } from "../id/id" +import type { Agent } from "../agent/agent" +import { evaluate } from "../permission/evaluate" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap } from "effect" +import { ToolID } from "./schema" const log = Log.create({ service: "truncation" }) const RETENTION = Duration.days(7) +export const MAX_LINES = 2000 +export const MAX_BYTES = 50 * 1024 + +export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string } + +export interface Options { + maxLines?: number + maxBytes?: number + direction?: "head" | "tail" +} + +function hasTaskTool(agent?: Agent.Info) { + if (!agent?.permission) return false + return evaluate("task", "*", agent.permission).action !== "deny" +} + export namespace TruncateService { export interface Service { readonly cleanup: () => Effect.Effect + readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect } } @@ -36,17 +56,78 @@ export class TruncateService extends ServiceMap.Service 0 ? 1 : 0) + if (bytes + size > maxBytes) { + hitBytes = true + break + } + out.push(lines[i]) + bytes += size + } + } else { + for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) { + const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0) + if (bytes + size > maxBytes) { + hitBytes = true + break + } + out.unshift(lines[i]) + bytes += size + } + } + + const removed = hitBytes ? totalBytes - bytes : lines.length - out.length + const unit = hitBytes ? "bytes" : "lines" + const preview = out.join("\n") + const file = path.join(TRUNCATION_DIR, ToolID.ascending()) + + yield* fs.makeDirectory(TRUNCATION_DIR, { recursive: true }).pipe(Effect.orDie) + yield* fs.writeFileString(file, text).pipe(Effect.orDie) + + const hint = hasTaskTool(agent) + ? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.` + : `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.` + + return { + content: + direction === "head" + ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}` + : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`, + truncated: true, + outputPath: file, + } as const + }) + + // Start delayed hourly cleanup — scoped to runtime lifetime yield* cleanup().pipe( Effect.catchCause((cause) => { log.error("truncation cleanup failed", { cause: Cause.pretty(cause) }) return Effect.void }), Effect.repeat(Schedule.spaced(Duration.hours(1))), + Effect.delay(Duration.minutes(1)), Effect.forkScoped, ) - return TruncateService.of({ cleanup }) + return TruncateService.of({ cleanup, output }) }), ).pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer)) } diff --git a/packages/opencode/src/tool/truncation.ts b/packages/opencode/src/tool/truncation.ts index 9e8f3abb82..f5837e9715 100644 --- a/packages/opencode/src/tool/truncation.ts +++ b/packages/opencode/src/tool/truncation.ts @@ -1,91 +1,21 @@ import path from "path" -import { PermissionNext } from "../permission/next" import { TRUNCATION_DIR } from "./truncation-dir" import type { Agent } from "../agent/agent" -import { Filesystem } from "../util/filesystem" -import { ToolID } from "./schema" -import { TruncateService } from "./truncate-service" import { runtime } from "@/effect/runtime" +import * as S from "./truncate-service" export namespace Truncate { - export const MAX_LINES = 2000 - export const MAX_BYTES = 50 * 1024 + export const MAX_LINES = S.MAX_LINES + export const MAX_BYTES = S.MAX_BYTES export const DIR = TRUNCATION_DIR export const GLOB = path.join(TRUNCATION_DIR, "*") - export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string } + export type Result = S.Result - export interface Options { - maxLines?: number - maxBytes?: number - direction?: "head" | "tail" - } - - export async function cleanup() { - return runtime.runPromise(TruncateService.use((s) => s.cleanup())) - } - - function hasTaskTool(agent?: Agent.Info): boolean { - if (!agent?.permission) return false - const rule = PermissionNext.evaluate("task", "*", agent.permission) - return rule.action !== "deny" - } + export type Options = S.Options export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise { - const maxLines = options.maxLines ?? MAX_LINES - const maxBytes = options.maxBytes ?? MAX_BYTES - const direction = options.direction ?? "head" - const lines = text.split("\n") - const totalBytes = Buffer.byteLength(text, "utf-8") - - if (lines.length <= maxLines && totalBytes <= maxBytes) { - return { content: text, truncated: false } - } - - const out: string[] = [] - let i = 0 - let bytes = 0 - let hitBytes = false - - if (direction === "head") { - for (i = 0; i < lines.length && i < maxLines; i++) { - const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0) - if (bytes + size > maxBytes) { - hitBytes = true - break - } - out.push(lines[i]) - bytes += size - } - } else { - for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) { - const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0) - if (bytes + size > maxBytes) { - hitBytes = true - break - } - out.unshift(lines[i]) - bytes += size - } - } - - const removed = hitBytes ? totalBytes - bytes : lines.length - out.length - const unit = hitBytes ? "bytes" : "lines" - const preview = out.join("\n") - - const id = ToolID.ascending() - const filepath = path.join(DIR, id) - await Filesystem.write(filepath, text) - - const hint = hasTaskTool(agent) - ? `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.` - : `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse Grep to search the full content or Read with offset/limit to view specific sections.` - const message = - direction === "head" - ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}` - : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}` - - return { content: message, truncated: true, outputPath: filepath } + return runtime.runPromise(S.TruncateService.use((s) => s.output(text, options, agent))) } } diff --git a/packages/opencode/test/account/repo.test.ts b/packages/opencode/test/account/repo.test.ts index 74a6d7a570..fb12ddf701 100644 --- a/packages/opencode/test/account/repo.test.ts +++ b/packages/opencode/test/account/repo.test.ts @@ -4,7 +4,7 @@ import { Effect, Layer, Option } from "effect" import { AccountRepo } from "../../src/account/repo" import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema" import { Database } from "../../src/storage/db" -import { testEffect } from "../fixture/effect" +import { testEffect } from "../lib/effect" const truncate = Layer.effectDiscard( Effect.sync(() => { @@ -16,24 +16,21 @@ const truncate = Layer.effectDiscard( const it = testEffect(Layer.merge(AccountRepo.layer, truncate)) -it.effect( - "list returns empty when no accounts exist", +it.effect("list returns empty when no accounts exist", () => Effect.gen(function* () { const accounts = yield* AccountRepo.use((r) => r.list()) expect(accounts).toEqual([]) }), ) -it.effect( - "active returns none when no accounts exist", +it.effect("active returns none when no accounts exist", () => Effect.gen(function* () { const active = yield* AccountRepo.use((r) => r.active()) expect(Option.isNone(active)).toBe(true) }), ) -it.effect( - "persistAccount inserts and getRow retrieves", +it.effect("persistAccount inserts and getRow retrieves", () => Effect.gen(function* () { const id = AccountID.make("user-1") yield* AccountRepo.use((r) => @@ -59,8 +56,7 @@ it.effect( }), ) -it.effect( - "persistAccount sets the active account and org", +it.effect("persistAccount sets the active account and org", () => Effect.gen(function* () { const id1 = AccountID.make("user-1") const id2 = AccountID.make("user-2") @@ -97,8 +93,7 @@ it.effect( }), ) -it.effect( - "list returns all accounts", +it.effect("list returns all accounts", () => Effect.gen(function* () { const id1 = AccountID.make("user-1") const id2 = AccountID.make("user-2") @@ -133,8 +128,7 @@ it.effect( }), ) -it.effect( - "remove deletes an account", +it.effect("remove deletes an account", () => Effect.gen(function* () { const id = AccountID.make("user-1") @@ -157,8 +151,7 @@ it.effect( }), ) -it.effect( - "use stores the selected org and marks the account active", +it.effect("use stores the selected org and marks the account active", () => Effect.gen(function* () { const id1 = AccountID.make("user-1") const id2 = AccountID.make("user-2") @@ -198,8 +191,7 @@ it.effect( }), ) -it.effect( - "persistToken updates token fields", +it.effect("persistToken updates token fields", () => Effect.gen(function* () { const id = AccountID.make("user-1") @@ -233,8 +225,7 @@ it.effect( }), ) -it.effect( - "persistToken with no expiry sets token_expiry to null", +it.effect("persistToken with no expiry sets token_expiry to null", () => Effect.gen(function* () { const id = AccountID.make("user-1") @@ -264,8 +255,7 @@ it.effect( }), ) -it.effect( - "persistAccount upserts on conflict", +it.effect("persistAccount upserts on conflict", () => Effect.gen(function* () { const id = AccountID.make("user-1") @@ -305,8 +295,7 @@ it.effect( }), ) -it.effect( - "remove clears active state when deleting the active account", +it.effect("remove clears active state when deleting the active account", () => Effect.gen(function* () { const id = AccountID.make("user-1") @@ -329,8 +318,7 @@ it.effect( }), ) -it.effect( - "getRow returns none for nonexistent account", +it.effect("getRow returns none for nonexistent account", () => Effect.gen(function* () { const row = yield* AccountRepo.use((r) => r.getRow(AccountID.make("nope"))) expect(Option.isNone(row)).toBe(true) diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts index 5caa33235a..ca244c2d94 100644 --- a/packages/opencode/test/account/service.test.ts +++ b/packages/opencode/test/account/service.test.ts @@ -1,12 +1,12 @@ import { expect } from "bun:test" -import { Duration, Effect, Layer, Option, Ref, Schema } from "effect" +import { Duration, Effect, Layer, Option, Schema } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { AccountRepo } from "../../src/account/repo" import { AccountService } from "../../src/account/service" import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema" import { Database } from "../../src/storage/db" -import { testEffect } from "../fixture/effect" +import { testEffect } from "../lib/effect" const truncate = Layer.effectDiscard( Effect.sync(() => { @@ -34,8 +34,7 @@ const encodeOrg = Schema.encodeSync(Org) const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name })) -it.effect( - "orgsByAccount groups orgs per account", +it.effect("orgsByAccount groups orgs per account", () => Effect.gen(function* () { yield* AccountRepo.use((r) => r.persistAccount({ @@ -61,10 +60,10 @@ it.effect( }), ) - const seen = yield* Ref.make([]) + const seen: Array = [] const client = HttpClient.make((req) => Effect.gen(function* () { - yield* Ref.update(seen, (xs) => [...xs, `${req.method} ${req.url}`]) + seen.push(`${req.method} ${req.url}`) if (req.url === "https://one.example.com/api/orgs") { return json(req, [org("org-1", "One")]) @@ -84,15 +83,14 @@ it.effect( [AccountID.make("user-1"), [OrgID.make("org-1")]], [AccountID.make("user-2"), [OrgID.make("org-2"), OrgID.make("org-3")]], ]) - expect(yield* Ref.get(seen)).toEqual([ + expect(seen).toEqual([ "GET https://one.example.com/api/orgs", "GET https://two.example.com/api/orgs", ]) }), ) -it.effect( - "token refresh persists the new token", +it.effect("token refresh persists the new token", () => Effect.gen(function* () { const id = AccountID.make("user-1") @@ -133,8 +131,7 @@ it.effect( }), ) -it.effect( - "config sends the selected org header", +it.effect("config sends the selected org header", () => Effect.gen(function* () { const id = AccountID.make("user-1") @@ -150,13 +147,11 @@ it.effect( }), ) - const seen = yield* Ref.make<{ auth?: string; org?: string }>({}) + const seen: { auth?: string; org?: string } = {} const client = HttpClient.make((req) => Effect.gen(function* () { - yield* Ref.set(seen, { - auth: req.headers.authorization, - org: req.headers["x-org-id"], - }) + seen.auth = req.headers.authorization + seen.org = req.headers["x-org-id"] if (req.url === "https://one.example.com/api/config") { return json(req, { config: { theme: "light", seats: 5 } }) @@ -169,15 +164,14 @@ it.effect( const cfg = yield* AccountService.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client))) expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 }) - expect(yield* Ref.get(seen)).toEqual({ + expect(seen).toEqual({ auth: "Bearer at_1", org: "org-9", }) }), ) -it.effect( - "poll stores the account and first org on success", +it.effect("poll stores the account and first org on success", () => Effect.gen(function* () { const login = new Login({ code: DeviceCode.make("device-code"), diff --git a/packages/opencode/test/fixture/effect.ts b/packages/opencode/test/fixture/effect.ts deleted file mode 100644 index b75610139f..0000000000 --- a/packages/opencode/test/fixture/effect.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { test } from "bun:test" -import { Effect, Layer } from "effect" - -export const testEffect = (layer: Layer.Layer) => ({ - effect: (name: string, value: Effect.Effect) => - test(name, () => Effect.runPromise(value.pipe(Effect.provide(layer)))), -}) diff --git a/packages/opencode/test/lib/effect.ts b/packages/opencode/test/lib/effect.ts new file mode 100644 index 0000000000..4162ba0924 --- /dev/null +++ b/packages/opencode/test/lib/effect.ts @@ -0,0 +1,37 @@ +import { test, type TestOptions } from "bun:test" +import { Cause, Effect, Exit, Layer } from "effect" +import type * as Scope from "effect/Scope" +import * as TestConsole from "effect/testing/TestConsole" + +type Body = Effect.Effect | (() => Effect.Effect) +const env = TestConsole.layer + +const body = (value: Body) => Effect.suspend(() => (typeof value === "function" ? value() : value)) + +const run = (value: Body, layer: Layer.Layer) => + Effect.gen(function* () { + const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit) + if (Exit.isFailure(exit)) { + for (const err of Cause.prettyErrors(exit.cause)) { + yield* Effect.logError(err) + } + } + return yield* exit + }).pipe(Effect.runPromise) + +const make = (layer: Layer.Layer) => { + const effect = (name: string, value: Body, opts?: number | TestOptions) => + test(name, () => run(value, layer), opts) + + effect.only = (name: string, value: Body, opts?: number | TestOptions) => + test.only(name, () => run(value, layer), opts) + + effect.skip = (name: string, value: Body, opts?: number | TestOptions) => + test.skip(name, () => run(value, layer), opts) + + return { effect } +} + +export const it = make(env) + +export const testEffect = (layer: Layer.Layer) => make(Layer.provideMerge(layer, env)) diff --git a/packages/opencode/test/lib/filesystem.ts b/packages/opencode/test/lib/filesystem.ts new file mode 100644 index 0000000000..66f702ec3d --- /dev/null +++ b/packages/opencode/test/lib/filesystem.ts @@ -0,0 +1,10 @@ +import path from "path" +import { Effect, FileSystem } from "effect" + +export const writeFileStringScoped = Effect.fn("test.writeFileStringScoped")(function* (file: string, text: string) { + const fs = yield* FileSystem.FileSystem + yield* fs.makeDirectory(path.dirname(file), { recursive: true }) + yield* fs.writeFileString(file, text) + yield* Effect.addFinalizer(() => fs.remove(file, { force: true }).pipe(Effect.orDie)) + return file +}) diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index 9e141b205d..cdc88fddab 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -1,9 +1,13 @@ -import { describe, test, expect, afterAll } from "bun:test" +import { describe, test, expect } from "bun:test" +import { NodeFileSystem } from "@effect/platform-node" +import { Effect, FileSystem, Layer } from "effect" import { Truncate } from "../../src/tool/truncation" +import { TruncateService } from "../../src/tool/truncate-service" import { Identifier } from "../../src/id/id" import { Filesystem } from "../../src/util/filesystem" -import fs from "fs/promises" import path from "path" +import { testEffect } from "../lib/effect" +import { writeFileStringScoped } from "../lib/filesystem" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") @@ -125,36 +129,24 @@ describe("Truncate", () => { describe("cleanup", () => { const DAY_MS = 24 * 60 * 60 * 1000 - let oldFile: string - let recentFile: string + const it = testEffect(Layer.mergeAll(TruncateService.layer, NodeFileSystem.layer)) - afterAll(async () => { - await fs.unlink(oldFile).catch(() => {}) - await fs.unlink(recentFile).catch(() => {}) - }) + it.effect("deletes files older than 7 days and preserves recent files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem - test("deletes files older than 7 days and preserves recent files", async () => { - await fs.mkdir(Truncate.DIR, { recursive: true }) + yield* fs.makeDirectory(Truncate.DIR, { recursive: true }) - // Create an old file (10 days ago) - const oldTimestamp = Date.now() - 10 * DAY_MS - const oldId = Identifier.create("tool", false, oldTimestamp) - oldFile = path.join(Truncate.DIR, oldId) - await Filesystem.write(oldFile, "old content") + const old = path.join(Truncate.DIR, Identifier.create("tool", false, Date.now() - 10 * DAY_MS)) + const recent = path.join(Truncate.DIR, Identifier.create("tool", false, Date.now() - 3 * DAY_MS)) - // Create a recent file (3 days ago) - const recentTimestamp = Date.now() - 3 * DAY_MS - const recentId = Identifier.create("tool", false, recentTimestamp) - recentFile = path.join(Truncate.DIR, recentId) - await Filesystem.write(recentFile, "recent content") + yield* writeFileStringScoped(old, "old content") + yield* writeFileStringScoped(recent, "recent content") + yield* TruncateService.use((s) => s.cleanup()) - await Truncate.cleanup() - - // Old file should be deleted - expect(await Filesystem.exists(oldFile)).toBe(false) - - // Recent file should still exist - expect(await Filesystem.exists(recentFile)).toBe(true) - }) + expect(yield* fs.exists(old)).toBe(false) + expect(yield* fs.exists(recent)).toBe(true) + }), + ) }) })