From ab89f84b0c0d5803148530aa5e94edec308d021e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 16 Mar 2026 21:44:17 -0400 Subject: [PATCH 1/2] refactor(snapshot): effectify SnapshotService as scoped service Convert Snapshot from a promise-based namespace with Instance ALS reads to an Effect service on the Instances LayerMap. - SnapshotService with ChildProcessSpawner for git subprocess execution and Effect FileSystem for file operations (replaces Process.run and raw fs calls) - Nothrow git helper that always returns { code, text, stderr }, with spawn failure details preserved in stderr - Hourly cleanup via Effect.forkScoped + Schedule.spaced (replaces Scheduler.register) - Promise facade preserved for all existing callers - Parallelized before/after git show in diffFull - Add worktree to InstanceContext.Shape (needed for --work-tree flag) - Add Instance.current getter for single ALS read - Extract repeated git config flags into GIT_CORE/GIT_CFG/GIT_CFG_QUOTE constants - Platform layers (NodeChildProcessSpawner, NodeFileSystem, NodePath) provided directly on the service layer --- .../opencode/src/effect/instance-context.ts | 20 +- packages/opencode/src/effect/instances.ts | 129 +-- packages/opencode/src/project/instance.ts | 306 +++--- packages/opencode/src/snapshot/index.ts | 926 ++++++++++-------- packages/opencode/test/fixture/instance.ts | 70 +- 5 files changed, 800 insertions(+), 651 deletions(-) diff --git a/packages/opencode/src/effect/instance-context.ts b/packages/opencode/src/effect/instance-context.ts index 583b52d5621..af5f9236fc5 100644 --- a/packages/opencode/src/effect/instance-context.ts +++ b/packages/opencode/src/effect/instance-context.ts @@ -1,13 +1,15 @@ -import { ServiceMap } from "effect" -import type { Project } from "@/project/project" +import { ServiceMap } from "effect"; +import type { Project } from "@/project/project"; export declare namespace InstanceContext { - export interface Shape { - readonly directory: string - readonly project: Project.Info - } + export interface Shape { + readonly directory: string; + readonly worktree: string; + readonly project: Project.Info; + } } -export class InstanceContext extends ServiceMap.Service()( - "opencode/InstanceContext", -) {} +export class InstanceContext extends ServiceMap.Service< + InstanceContext, + InstanceContext.Shape +>()("opencode/InstanceContext") {} diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index eabf1986886..075663f080a 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -1,64 +1,83 @@ -import { Effect, Layer, LayerMap, ServiceMap } from "effect" -import { registerDisposer } from "./instance-registry" -import { InstanceContext } from "./instance-context" -import { ProviderAuthService } from "@/provider/auth-service" -import { QuestionService } from "@/question/service" -import { PermissionService } from "@/permission/service" -import { FileWatcherService } from "@/file/watcher" -import { VcsService } from "@/project/vcs" -import { FileTimeService } from "@/file/time" -import { FormatService } from "@/format" -import { FileService } from "@/file" -import { SkillService } from "@/skill/skill" -import { Instance } from "@/project/instance" +import { Effect, Layer, LayerMap, ServiceMap } from "effect"; +import { FileService } from "@/file"; +import { FileTimeService } from "@/file/time"; +import { FileWatcherService } from "@/file/watcher"; +import { FormatService } from "@/format"; +import { PermissionService } from "@/permission/service"; +import { Instance } from "@/project/instance"; +import { VcsService } from "@/project/vcs"; +import { ProviderAuthService } from "@/provider/auth-service"; +import { QuestionService } from "@/question/service"; +import { SkillService } from "@/skill/skill"; +import { SnapshotService } from "@/snapshot"; +import { InstanceContext } from "./instance-context"; +import { registerDisposer } from "./instance-registry"; -export { InstanceContext } from "./instance-context" +export { InstanceContext } from "./instance-context"; export type InstanceServices = - | QuestionService - | PermissionService - | ProviderAuthService - | FileWatcherService - | VcsService - | FileTimeService - | FormatService - | FileService - | SkillService + | QuestionService + | PermissionService + | ProviderAuthService + | FileWatcherService + | VcsService + | FileTimeService + | FormatService + | FileService + | SkillService + | SnapshotService; -function lookup(directory: string) { - const project = Instance.project - const ctx = Layer.sync(InstanceContext, () => InstanceContext.of({ directory, project })) - return Layer.mergeAll( - Layer.fresh(QuestionService.layer), - Layer.fresh(PermissionService.layer), - Layer.fresh(ProviderAuthService.layer), - Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie), - Layer.fresh(VcsService.layer), - Layer.fresh(FileTimeService.layer).pipe(Layer.orDie), - Layer.fresh(FormatService.layer), - Layer.fresh(FileService.layer), - Layer.fresh(SkillService.layer), - ).pipe(Layer.provide(ctx)) +// NOTE: LayerMap only passes the key (directory string) to lookup, but we need +// the full instance context (directory, worktree, project). We read from the +// legacy Instance ALS here, which is safe because lookup is only triggered via +// runPromiseInstance -> Instances.get, which always runs inside Instance.provide. +// This should go away once the old Instance type is removed and lookup can load +// the full context directly. +function lookup(_key: string) { + const ctx = Layer.sync(InstanceContext, () => + InstanceContext.of(Instance.current), + ); + return Layer.mergeAll( + Layer.fresh(QuestionService.layer), + Layer.fresh(PermissionService.layer), + Layer.fresh(ProviderAuthService.layer), + Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie), + Layer.fresh(VcsService.layer), + Layer.fresh(FileTimeService.layer).pipe(Layer.orDie), + Layer.fresh(FormatService.layer), + Layer.fresh(FileService.layer), + Layer.fresh(SkillService.layer), + Layer.fresh(SnapshotService.layer), + ).pipe(Layer.provide(ctx)); } -export class Instances extends ServiceMap.Service>()( - "opencode/Instances", -) { - static readonly layer = Layer.effect( - Instances, - Effect.gen(function* () { - const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity }) - const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory))) - yield* Effect.addFinalizer(() => Effect.sync(unregister)) - return Instances.of(layerMap) - }), - ) +export class Instances extends ServiceMap.Service< + Instances, + LayerMap.LayerMap +>()("opencode/Instances") { + static readonly layer = Layer.effect( + Instances, + Effect.gen(function* () { + const layerMap = yield* LayerMap.make(lookup, { + idleTimeToLive: Infinity, + }); + const unregister = registerDisposer((directory) => + Effect.runPromise(layerMap.invalidate(directory)), + ); + yield* Effect.addFinalizer(() => Effect.sync(unregister)); + return Instances.of(layerMap); + }), + ); - static get(directory: string): Layer.Layer { - return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory)))) - } + static get( + directory: string, + ): Layer.Layer { + return Layer.unwrap( + Instances.use((map) => Effect.succeed(map.get(directory))), + ); + } - static invalidate(directory: string): Effect.Effect { - return Instances.use((map) => map.invalidate(directory)) - } + static invalidate(directory: string): Effect.Effect { + return Instances.use((map) => map.invalidate(directory)); + } } diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index c16801a7a12..61f6dd79318 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -1,163 +1,185 @@ -import { Log } from "@/util/log" -import { Context } from "../util/context" -import { Project } from "./project" -import { State } from "./state" -import { iife } from "@/util/iife" -import { GlobalBus } from "@/bus/global" -import { Filesystem } from "@/util/filesystem" -import { disposeInstance } from "@/effect/instance-registry" +import { GlobalBus } from "@/bus/global"; +import { disposeInstance } from "@/effect/instance-registry"; +import { Filesystem } from "@/util/filesystem"; +import { iife } from "@/util/iife"; +import { Log } from "@/util/log"; +import { Context } from "../util/context"; +import { Project } from "./project"; +import { State } from "./state"; interface Context { - directory: string - worktree: string - project: Project.Info + directory: string; + worktree: string; + project: Project.Info; } -const context = Context.create("instance") -const cache = new Map>() +const context = Context.create("instance"); +const cache = new Map>(); const disposal = { - all: undefined as Promise | undefined, -} + all: undefined as Promise | undefined, +}; function emit(directory: string) { - GlobalBus.emit("event", { - directory, - payload: { - type: "server.instance.disposed", - properties: { - directory, - }, - }, - }) + GlobalBus.emit("event", { + directory, + payload: { + type: "server.instance.disposed", + properties: { + directory, + }, + }, + }); } -function boot(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { - return iife(async () => { - const ctx = - input.project && input.worktree - ? { - directory: input.directory, - worktree: input.worktree, - project: input.project, - } - : await Project.fromDirectory(input.directory).then(({ project, sandbox }) => ({ - directory: input.directory, - worktree: sandbox, - project, - })) - await context.provide(ctx, async () => { - await input.init?.() - }) - return ctx - }) +function boot(input: { + directory: string; + init?: () => Promise; + project?: Project.Info; + worktree?: string; +}) { + return iife(async () => { + const ctx = + input.project && input.worktree + ? { + directory: input.directory, + worktree: input.worktree, + project: input.project, + } + : await Project.fromDirectory(input.directory).then( + ({ project, sandbox }) => ({ + directory: input.directory, + worktree: sandbox, + project, + }), + ); + await context.provide(ctx, async () => { + await input.init?.(); + }); + return ctx; + }); } function track(directory: string, next: Promise) { - const task = next.catch((error) => { - if (cache.get(directory) === task) cache.delete(directory) - throw error - }) - cache.set(directory, task) - return task + const task = next.catch((error) => { + if (cache.get(directory) === task) cache.delete(directory); + throw error; + }); + cache.set(directory, task); + return task; } export const Instance = { - async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { - const directory = Filesystem.resolve(input.directory) - let existing = cache.get(directory) - if (!existing) { - Log.Default.info("creating instance", { directory }) - existing = track( - directory, - boot({ - directory, - init: input.init, - }), - ) - } - const ctx = await existing - return context.provide(ctx, async () => { - return input.fn() - }) - }, - get directory() { - return context.use().directory - }, - get worktree() { - return context.use().worktree - }, - get project() { - return context.use().project - }, - /** - * Check if a path is within the project boundary. - * Returns true if path is inside Instance.directory OR Instance.worktree. - * Paths within the worktree but outside the working directory should not trigger external_directory permission. - */ - containsPath(filepath: string) { - if (Filesystem.contains(Instance.directory, filepath)) return true - // Non-git projects set worktree to "/" which would match ANY absolute path. - // Skip worktree check in this case to preserve external_directory permissions. - if (Instance.worktree === "/") return false - return Filesystem.contains(Instance.worktree, filepath) - }, - /** - * Captures the current instance ALS context and returns a wrapper that - * restores it when called. Use this for callbacks that fire outside the - * instance async context (native addons, event emitters, timers, etc.). - */ - bind any>(fn: F): F { - const ctx = context.use() - return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F - }, - state(init: () => S, dispose?: (state: Awaited) => Promise): () => S { - return State.create(() => Instance.directory, init, dispose) - }, - async reload(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { - const directory = Filesystem.resolve(input.directory) - Log.Default.info("reloading instance", { directory }) - await Promise.all([State.dispose(directory), disposeInstance(directory)]) - cache.delete(directory) - const next = track(directory, boot({ ...input, directory })) - emit(directory) - return await next - }, - async dispose() { - const directory = Instance.directory - Log.Default.info("disposing instance", { directory }) - await Promise.all([State.dispose(directory), disposeInstance(directory)]) - cache.delete(directory) - emit(directory) - }, - async disposeAll() { - if (disposal.all) return disposal.all + async provide(input: { + directory: string; + init?: () => Promise; + fn: () => R; + }): Promise { + const directory = Filesystem.resolve(input.directory); + let existing = cache.get(directory); + if (!existing) { + Log.Default.info("creating instance", { directory }); + existing = track( + directory, + boot({ + directory, + init: input.init, + }), + ); + } + const ctx = await existing; + return context.provide(ctx, async () => { + return input.fn(); + }); + }, + get current() { + return context.use(); + }, + get directory() { + return context.use().directory; + }, + get worktree() { + return context.use().worktree; + }, + get project() { + return context.use().project; + }, + /** + * Check if a path is within the project boundary. + * Returns true if path is inside Instance.directory OR Instance.worktree. + * Paths within the worktree but outside the working directory should not trigger external_directory permission. + */ + containsPath(filepath: string) { + if (Filesystem.contains(Instance.directory, filepath)) return true; + // Non-git projects set worktree to "/" which would match ANY absolute path. + // Skip worktree check in this case to preserve external_directory permissions. + if (Instance.worktree === "/") return false; + return Filesystem.contains(Instance.worktree, filepath); + }, + /** + * Captures the current instance ALS context and returns a wrapper that + * restores it when called. Use this for callbacks that fire outside the + * instance async context (native addons, event emitters, timers, etc.). + */ + bind any>(fn: F): F { + const ctx = context.use(); + return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F; + }, + state( + init: () => S, + dispose?: (state: Awaited) => Promise, + ): () => S { + return State.create(() => Instance.directory, init, dispose); + }, + async reload(input: { + directory: string; + init?: () => Promise; + project?: Project.Info; + worktree?: string; + }) { + const directory = Filesystem.resolve(input.directory); + Log.Default.info("reloading instance", { directory }); + await Promise.all([State.dispose(directory), disposeInstance(directory)]); + cache.delete(directory); + const next = track(directory, boot({ ...input, directory })); + emit(directory); + return await next; + }, + async dispose() { + const directory = Instance.directory; + Log.Default.info("disposing instance", { directory }); + await Promise.all([State.dispose(directory), disposeInstance(directory)]); + cache.delete(directory); + emit(directory); + }, + async disposeAll() { + if (disposal.all) return disposal.all; - disposal.all = iife(async () => { - Log.Default.info("disposing all instances") - const entries = [...cache.entries()] - for (const [key, value] of entries) { - if (cache.get(key) !== value) continue + disposal.all = iife(async () => { + Log.Default.info("disposing all instances"); + const entries = [...cache.entries()]; + for (const [key, value] of entries) { + if (cache.get(key) !== value) continue; - const ctx = await value.catch((error) => { - Log.Default.warn("instance dispose failed", { key, error }) - return undefined - }) + const ctx = await value.catch((error) => { + Log.Default.warn("instance dispose failed", { key, error }); + return undefined; + }); - if (!ctx) { - if (cache.get(key) === value) cache.delete(key) - continue - } + if (!ctx) { + if (cache.get(key) === value) cache.delete(key); + continue; + } - if (cache.get(key) !== value) continue + if (cache.get(key) !== value) continue; - await context.provide(ctx, async () => { - await Instance.dispose() - }) - } - }).finally(() => { - disposal.all = undefined - }) + await context.provide(ctx, async () => { + await Instance.dispose(); + }); + } + }).finally(() => { + disposal.all = undefined; + }); - return disposal.all - }, -} + return disposal.all; + }, +}; diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 72252b7b4c5..ccba830b817 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -1,416 +1,516 @@ -import path from "path" -import fs from "fs/promises" -import { Filesystem } from "../util/filesystem" -import { Log } from "../util/log" -import { Flag } from "../flag/flag" -import { Global } from "../global" -import z from "zod" -import { Config } from "../config/config" -import { Instance } from "../project/instance" -import { Scheduler } from "../scheduler" -import { Process } from "@/util/process" +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"; + +// 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"]; + +interface GitResult { + readonly code: ChildProcessSpawner.ExitCode; + readonly text: string; + readonly stderr: string; +} export namespace Snapshot { - const log = Log.create({ service: "snapshot" }) - const hour = 60 * 60 * 1000 - const prune = "7.days" - - function args(git: string, cmd: string[]) { - return ["--git-dir", git, "--work-tree", Instance.worktree, ...cmd] - } - - export function init() { - Scheduler.register({ - id: "snapshot.cleanup", - interval: hour, - run: cleanup, - scope: "instance", - }) - } - - export async function cleanup() { - if (Instance.project.vcs !== "git") return - const cfg = await Config.get() - if (cfg.snapshot === false) return - const git = gitdir() - const exists = await fs - .stat(git) - .then(() => true) - .catch(() => false) - if (!exists) return - const result = await Process.run(["git", ...args(git, ["gc", `--prune=${prune}`])], { - cwd: Instance.directory, - nothrow: true, - }) - if (result.code !== 0) { - log.warn("cleanup failed", { - exitCode: result.code, - stderr: result.stderr.toString(), - stdout: result.stdout.toString(), - }) - return - } - log.info("cleanup", { prune }) - } - - export async function track() { - if (Instance.project.vcs !== "git") return - const cfg = await Config.get() - if (cfg.snapshot === false) return - const git = gitdir() - if (await fs.mkdir(git, { recursive: true })) { - await Process.run(["git", "init"], { - env: { - ...process.env, - GIT_DIR: git, - GIT_WORK_TREE: Instance.worktree, - }, - nothrow: true, - }) - - // Configure git to not convert line endings on Windows - await Process.run(["git", "--git-dir", git, "config", "core.autocrlf", "false"], { nothrow: true }) - await Process.run(["git", "--git-dir", git, "config", "core.longpaths", "true"], { nothrow: true }) - await Process.run(["git", "--git-dir", git, "config", "core.symlinks", "true"], { nothrow: true }) - await Process.run(["git", "--git-dir", git, "config", "core.fsmonitor", "false"], { nothrow: true }) - log.info("initialized") - } - await add(git) - const hash = await Process.text(["git", ...args(git, ["write-tree"])], { - cwd: Instance.directory, - nothrow: true, - }).then((x) => x.text) - log.info("tracking", { hash, cwd: Instance.directory, git }) - return hash.trim() - } - - export const Patch = z.object({ - hash: z.string(), - files: z.string().array(), - }) - export type Patch = z.infer - - export async function patch(hash: string): Promise { - const git = gitdir() - await add(git) - const result = await Process.text( - [ - "git", - "-c", - "core.autocrlf=false", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - "-c", - "core.quotepath=false", - ...args(git, ["diff", "--no-ext-diff", "--name-only", hash, "--", "."]), - ], - { - cwd: Instance.directory, - nothrow: true, - }, - ) - - // If git diff fails, return empty patch - if (result.code !== 0) { - log.warn("failed to get diff", { hash, exitCode: result.code }) - return { hash, files: [] } - } - - const files = result.text - return { - hash, - files: files - .trim() - .split("\n") - .map((x) => x.trim()) - .filter(Boolean) - .map((x) => path.join(Instance.worktree, x).replaceAll("\\", "/")), - } - } - - export async function restore(snapshot: string) { - log.info("restore", { commit: snapshot }) - const git = gitdir() - const result = await Process.run( - ["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["read-tree", snapshot])], - { - cwd: Instance.worktree, - nothrow: true, - }, - ) - if (result.code === 0) { - const checkout = await Process.run( - ["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["checkout-index", "-a", "-f"])], - { - cwd: Instance.worktree, - nothrow: true, - }, - ) - if (checkout.code === 0) return - log.error("failed to restore snapshot", { - snapshot, - exitCode: checkout.code, - stderr: checkout.stderr.toString(), - stdout: checkout.stdout.toString(), - }) - return - } - - log.error("failed to restore snapshot", { - snapshot, - exitCode: result.code, - stderr: result.stderr.toString(), - stdout: result.stdout.toString(), - }) - } - - export async function revert(patches: Patch[]) { - const files = new Set() - const git = gitdir() - for (const item of patches) { - for (const file of item.files) { - if (files.has(file)) continue - log.info("reverting", { file, hash: item.hash }) - const result = await Process.run( - [ - "git", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - ...args(git, ["checkout", item.hash, "--", file]), - ], - { - cwd: Instance.worktree, - nothrow: true, - }, - ) - if (result.code !== 0) { - const relativePath = path.relative(Instance.worktree, file) - const checkTree = await Process.text( - [ - "git", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - ...args(git, ["ls-tree", item.hash, "--", relativePath]), - ], - { - cwd: Instance.worktree, - nothrow: true, - }, - ) - 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 }) - await fs.unlink(file).catch(() => {}) - } - } - files.add(file) - } - } - } - - export async function diff(hash: string) { - const git = gitdir() - await add(git) - const result = await Process.text( - [ - "git", - "-c", - "core.autocrlf=false", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - "-c", - "core.quotepath=false", - ...args(git, ["diff", "--no-ext-diff", hash, "--", "."]), - ], - { - cwd: Instance.worktree, - nothrow: true, - }, - ) - - if (result.code !== 0) { - log.warn("failed to get diff", { - hash, - exitCode: result.code, - stderr: result.stderr.toString(), - stdout: result.stdout.toString(), - }) - return "" - } - - return result.text.trim() - } - - 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 async function diffFull(from: string, to: string): Promise { - const git = gitdir() - const result: FileDiff[] = [] - const status = new Map() - - const statuses = await Process.text( - [ - "git", - "-c", - "core.autocrlf=false", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - "-c", - "core.quotepath=false", - ...args(git, ["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]), - ], - { - cwd: Instance.directory, - nothrow: true, - }, - ).then((x) => x.text) - - for (const line of statuses.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 await Process.lines( - [ - "git", - "-c", - "core.autocrlf=false", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - "-c", - "core.quotepath=false", - ...args(git, ["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."]), - ], - { - cwd: Instance.directory, - nothrow: true, - }, - )) { - if (!line) continue - const [additions, deletions, file] = line.split("\t") - const isBinaryFile = additions === "-" && deletions === "-" - const before = isBinaryFile - ? "" - : await Process.text( - [ - "git", - "-c", - "core.autocrlf=false", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - ...args(git, ["show", `${from}:${file}`]), - ], - { nothrow: true }, - ).then((x) => x.text) - const after = isBinaryFile - ? "" - : await Process.text( - [ - "git", - "-c", - "core.autocrlf=false", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - ...args(git, ["show", `${to}:${file}`]), - ], - { nothrow: true }, - ).then((x) => x.text) - const added = isBinaryFile ? 0 : parseInt(additions) - const deleted = isBinaryFile ? 0 : parseInt(deletions) - result.push({ - file, - before, - after, - additions: Number.isFinite(added) ? added : 0, - deletions: Number.isFinite(deleted) ? deleted : 0, - status: status.get(file) ?? "modified", - }) - } - return result - } - - function gitdir() { - const project = Instance.project - return path.join(Global.Path.data, "snapshot", project.id) - } - - async function add(git: string) { - await syncExclude(git) - await Process.run( - [ - "git", - "-c", - "core.autocrlf=false", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - ...args(git, ["add", "."]), - ], - { - cwd: Instance.directory, - nothrow: true, - }, - ) - } - - async function syncExclude(git: string) { - const file = await excludes() - const target = path.join(git, "info", "exclude") - await fs.mkdir(path.join(git, "info"), { recursive: true }) - if (!file) { - await Filesystem.write(target, "") - return - } - const text = await Filesystem.readText(file).catch(() => "") - - await Filesystem.write(target, text) - } - - async function excludes() { - const file = await Process.text(["git", "rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], { - cwd: Instance.worktree, - nothrow: true, - }).then((x) => x.text) - if (!file.trim()) return - const exists = await fs - .stat(file.trim()) - .then(() => true) - .catch(() => false) - if (!exists) return - return file.trim() - } + 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; + + // 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 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 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 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 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 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); + + 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), + }), + ), + ); + + // 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 --- + + 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 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 }); + }); + + // --- 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 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 }, + ); + + if (result.code !== 0) { + log.warn("failed to get diff", { hash, exitCode: result.code }); + return { hash, files: [] } as Snapshot.Patch; + } + + 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 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 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 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, + }, + ); + + if (result.code !== 0) { + log.warn("failed to get diff", { + hash, + exitCode: result.code, + stderr: result.stderr, + }); + return ""; + } + + return result.text.trim(); + }); + + const diffFull = Effect.fn("SnapshotService.diffFull")(function* ( + from: string, + to: string, + ) { + const result: Snapshot.FileDiff[] = []; + const status = new Map(); + + const statuses = yield* git( + [ + ...GIT_CFG_QUOTE, + ...gitArgs([ + "diff", + "--no-ext-diff", + "--name-status", + "--no-renames", + 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); + } + + const numstat = yield* git( + [ + ...GIT_CFG_QUOTE, + ...gitArgs([ + "diff", + "--no-ext-diff", + "--no-renames", + "--numstat", + from, + to, + "--", + ".", + ]), + ], + { cwd: directory }, + ); + + 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; + }); + + // 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, + ); + + 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), + ); } diff --git a/packages/opencode/test/fixture/instance.ts b/packages/opencode/test/fixture/instance.ts index d322e1d9fbe..1a7096b6357 100644 --- a/packages/opencode/test/fixture/instance.ts +++ b/packages/opencode/test/fixture/instance.ts @@ -1,14 +1,14 @@ -import { ConfigProvider, Layer, ManagedRuntime } from "effect" -import { InstanceContext } from "../../src/effect/instance-context" -import { Instance } from "../../src/project/instance" +import { ConfigProvider, Layer, ManagedRuntime } from "effect"; +import { InstanceContext } from "../../src/effect/instance-context"; +import { Instance } from "../../src/project/instance"; /** ConfigProvider that enables the experimental file watcher. */ export const watcherConfigLayer = ConfigProvider.layer( - ConfigProvider.fromUnknown({ - OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", - OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false", - }), -) + ConfigProvider.fromUnknown({ + OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false", + }), +); /** * Boot an Instance with the given service layers and run `body` with @@ -19,29 +19,35 @@ export const watcherConfigLayer = ConfigProvider.layer( * Pass extra layers via `options.provide` (e.g. ConfigProvider.layer). */ export function withServices( - directory: string, - layer: Layer.Layer, - body: (rt: ManagedRuntime.ManagedRuntime) => Promise, - options?: { provide?: Layer.Layer[] }, + directory: string, + layer: Layer.Layer, + body: (rt: ManagedRuntime.ManagedRuntime) => Promise, + options?: { provide?: Layer.Layer[] }, ) { - return Instance.provide({ - directory, - fn: async () => { - const ctx = Layer.sync(InstanceContext, () => - InstanceContext.of({ directory: Instance.directory, project: Instance.project }), - ) - let resolved: Layer.Layer = Layer.fresh(layer).pipe(Layer.provide(ctx)) as any - if (options?.provide) { - for (const l of options.provide) { - resolved = resolved.pipe(Layer.provide(l)) as any - } - } - const rt = ManagedRuntime.make(resolved) - try { - await body(rt) - } finally { - await rt.dispose() - } - }, - }) + return Instance.provide({ + directory, + fn: async () => { + const ctx = Layer.sync(InstanceContext, () => + InstanceContext.of({ + directory: Instance.directory, + worktree: Instance.worktree, + project: Instance.project, + }), + ); + let resolved: Layer.Layer = Layer.fresh(layer).pipe( + Layer.provide(ctx), + ) as any; + if (options?.provide) { + for (const l of options.provide) { + resolved = resolved.pipe(Layer.provide(l)) as any; + } + } + const rt = ManagedRuntime.make(resolved); + try { + await body(rt); + } finally { + await rt.dispose(); + } + }, + }); } From 50ab3718464742a074269497cbd888d2bd6e52a2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 17 Mar 2026 14:52:09 -0400 Subject: [PATCH 2/2] fix(e2e): dispose managed runtime after seeding --- packages/opencode/script/seed-e2e.ts | 72 +++++++++++++------------ packages/opencode/src/effect/runtime.ts | 4 ++ 2 files changed, 43 insertions(+), 33 deletions(-) diff --git a/packages/opencode/script/seed-e2e.ts b/packages/opencode/script/seed-e2e.ts index f34dd051db5..fc3573548d3 100644 --- a/packages/opencode/script/seed-e2e.ts +++ b/packages/opencode/script/seed-e2e.ts @@ -11,46 +11,52 @@ const seed = async () => { const { Instance } = await import("../src/project/instance") const { InstanceBootstrap } = await import("../src/project/bootstrap") const { Config } = await import("../src/config/config") + const { disposeRuntime } = await import("../src/effect/runtime") const { Session } = await import("../src/session") const { MessageID, PartID } = await import("../src/session/schema") const { Project } = await import("../src/project/project") const { ModelID, ProviderID } = await import("../src/provider/schema") const { ToolRegistry } = await import("../src/tool/registry") - await Instance.provide({ - directory: dir, - init: InstanceBootstrap, - fn: async () => { - await Config.waitForDependencies() - await ToolRegistry.ids() + try { + await Instance.provide({ + directory: dir, + init: InstanceBootstrap, + fn: async () => { + await Config.waitForDependencies() + await ToolRegistry.ids() - const session = await Session.create({ title }) - const messageID = MessageID.ascending() - const partID = PartID.ascending() - const message = { - id: messageID, - sessionID: session.id, - role: "user" as const, - time: { created: now }, - agent: "build", - model: { - providerID: ProviderID.make(providerID), - modelID: ModelID.make(modelID), - }, - } - const part = { - id: partID, - sessionID: session.id, - messageID, - type: "text" as const, - text, - time: { start: now }, - } - await Session.updateMessage(message) - await Session.updatePart(part) - await Project.update({ projectID: Instance.project.id, name: "E2E Project" }) - }, - }) + const session = await Session.create({ title }) + const messageID = MessageID.ascending() + const partID = PartID.ascending() + const message = { + id: messageID, + sessionID: session.id, + role: "user" as const, + time: { created: now }, + agent: "build", + model: { + providerID: ProviderID.make(providerID), + modelID: ModelID.make(modelID), + }, + } + const part = { + id: partID, + sessionID: session.id, + messageID, + type: "text" as const, + text, + time: { start: now }, + } + await Session.updateMessage(message) + await Session.updatePart(part) + await Project.update({ projectID: Instance.project.id, name: "E2E Project" }) + }, + }) + } finally { + await Instance.disposeAll().catch(() => {}) + await disposeRuntime().catch(() => {}) + } } await seed() diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts index 02a7391d44c..cf7d73f7768 100644 --- a/packages/opencode/src/effect/runtime.ts +++ b/packages/opencode/src/effect/runtime.ts @@ -12,3 +12,7 @@ export const runtime = ManagedRuntime.make( export function runPromiseInstance(effect: Effect.Effect) { return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory)))) } + +export function disposeRuntime() { + return runtime.dispose() +}