diff --git a/packages/opencode/specs/effect-migration.md b/packages/opencode/specs/effect-migration.md index 176190437c67..f4acc6e52e0b 100644 --- a/packages/opencode/specs/effect-migration.md +++ b/packages/opencode/specs/effect-migration.md @@ -8,8 +8,8 @@ Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need Use `makeRuntime` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. Returns `{ runPromise, runFork, runCallback }`. -- Global services (no per-directory state): Account, Auth, Installation, Truncate -- Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth +- Global services (no per-directory state): Account, Auth, AppFileSystem, Installation, Truncate, Worktree +- Instance-scoped (per-directory state via InstanceState): Agent, Bus, Command, Config, File, FileTime, FileWatcher, Format, LSP, MCP, Permission, Plugin, ProviderAuth, Pty, Question, SessionStatus, Skill, Snapshot, ToolRegistry, Vcs Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`. @@ -181,36 +181,39 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow Fully migrated (single namespace, InstanceState where needed, flattened facade): - [x] `Account` — `account/index.ts` +- [x] `Agent` — `agent/agent.ts` +- [x] `AppFileSystem` — `filesystem/index.ts` - [x] `Auth` — `auth/index.ts` (uses `zod()` helper for Schema→Zod interop) +- [x] `Bus` — `bus/index.ts` +- [x] `Command` — `command/index.ts` +- [x] `Config` — `config/config.ts` +- [x] `Discovery` — `skill/discovery.ts` (dependency-only layer, no standalone runtime) - [x] `File` — `file/index.ts` - [x] `FileTime` — `file/time.ts` - [x] `FileWatcher` — `file/watcher.ts` - [x] `Format` — `format/index.ts` - [x] `Installation` — `installation/index.ts` +- [x] `LSP` — `lsp/index.ts` +- [x] `MCP` — `mcp/index.ts` +- [x] `McpAuth` — `mcp/auth.ts` - [x] `Permission` — `permission/index.ts` +- [x] `Plugin` — `plugin/index.ts` +- [x] `Project` — `project/project.ts` - [x] `ProviderAuth` — `provider/auth.ts` +- [x] `Pty` — `pty/index.ts` - [x] `Question` — `question/index.ts` +- [x] `SessionStatus` — `session/status.ts` - [x] `Skill` — `skill/index.ts` - [x] `Snapshot` — `snapshot/index.ts` +- [x] `ToolRegistry` — `tool/registry.ts` - [x] `Truncate` — `tool/truncate.ts` - [x] `Vcs` — `project/vcs.ts` -- [x] `Discovery` — `skill/discovery.ts` -- [x] `SessionStatus` +- [x] `Worktree` — `worktree/index.ts` Still open and likely worth migrating: -- [x] `Plugin` -- [x] `ToolRegistry` -- [ ] `Pty` -- [x] `Worktree` -- [x] `Bus` -- [x] `Command` -- [x] `Config` - [ ] `Session` - [ ] `SessionProcessor` - [ ] `SessionPrompt` - [ ] `SessionCompaction` - [ ] `Provider` -- [x] `Project` -- [x] `LSP` -- [x] `MCP` diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 622537e3c182..53c655d1b373 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -72,13 +72,14 @@ export namespace Agent { export const layer = Layer.effect( Service, Effect.gen(function* () { - const config = () => Effect.promise(() => Config.get()) + const config = yield* Config.Service const auth = yield* Auth.Service + const skill = yield* Skill.Service const state = yield* InstanceState.make( Effect.fn("Agent.state")(function* (ctx) { - const cfg = yield* config() - const skillDirs = yield* Effect.promise(() => Skill.dirs()) + const cfg = yield* config.get() + const skillDirs = yield* skill.dirs() const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))] const defaults = Permission.fromConfig({ @@ -281,7 +282,7 @@ export namespace Agent { }) const list = Effect.fnUntraced(function* () { - const cfg = yield* config() + const cfg = yield* config.get() return pipe( agents, values(), @@ -293,7 +294,7 @@ export namespace Agent { }) const defaultAgent = Effect.fnUntraced(function* () { - const c = yield* config() + const c = yield* config.get() if (c.default_agent) { const agent = agents[c.default_agent] if (!agent) throw new Error(`default agent "${c.default_agent}" not found`) @@ -328,7 +329,7 @@ export namespace Agent { description: string model?: { providerID: ProviderID; modelID: ModelID } }) { - const cfg = yield* config() + const cfg = yield* config.get() const model = input.model ?? (yield* Effect.promise(() => Provider.defaultModel())) const resolved = yield* Effect.promise(() => Provider.getModel(model.providerID, model.modelID)) const language = yield* Effect.promise(() => Provider.getLanguage(resolved)) @@ -391,7 +392,11 @@ export namespace Agent { }), ) - export const defaultLayer = layer.pipe(Layer.provide(Auth.layer)) + export const defaultLayer = layer.pipe( + Layer.provide(Auth.layer), + Layer.provide(Config.defaultLayer), + Layer.provide(Skill.defaultLayer), + ) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index a2407982a3f2..8cdb57841933 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -75,8 +75,12 @@ export namespace Command { export const layer = Layer.effect( Service, Effect.gen(function* () { + const config = yield* Config.Service + const mcp = yield* MCP.Service + const skill = yield* Skill.Service + const init = Effect.fn("Command.state")(function* (ctx) { - const cfg = yield* Effect.promise(() => Config.get()) + const cfg = yield* config.get() const commands: Record = {} commands[Default.INIT] = { @@ -114,7 +118,7 @@ export namespace Command { } } - for (const [name, prompt] of Object.entries(yield* Effect.promise(() => MCP.prompts()))) { + for (const [name, prompt] of Object.entries(yield* mcp.prompts())) { commands[name] = { name, source: "mcp", @@ -139,14 +143,14 @@ export namespace Command { } } - for (const skill of yield* Effect.promise(() => Skill.all())) { - if (commands[skill.name]) continue - commands[skill.name] = { - name: skill.name, - description: skill.description, + for (const item of yield* skill.all()) { + if (commands[item.name]) continue + commands[item.name] = { + name: item.name, + description: item.description, source: "skill", get template() { - return skill.content + return item.content }, hints: [], } @@ -173,7 +177,13 @@ export namespace Command { }), ) - const { runPromise } = makeRuntime(Service, layer) + export const defaultLayer = layer.pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(MCP.defaultLayer), + Layer.provide(Skill.defaultLayer), + ) + + const { runPromise } = makeRuntime(Service, defaultLayer) export async function get(name: string) { return runPromise((svc) => svc.get(name)) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 67f298b427ed..6a912202cd8f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -40,7 +40,7 @@ import { Lock } from "@/util/lock" import { AppFileSystem } from "@/filesystem" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" -import { Duration, Effect, Layer, ServiceMap } from "effect" +import { Duration, Effect, Layer, Option, ServiceMap } from "effect" export namespace Config { const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }) @@ -1136,10 +1136,12 @@ export namespace Config { }), ) - export const layer: Layer.Layer = Layer.effect( + export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* AppFileSystem.Service + const authSvc = yield* Auth.Service + const accountSvc = yield* Account.Service const readConfigFile = Effect.fnUntraced(function* (filepath: string) { return yield* fs.readFileString(filepath).pipe( @@ -1256,7 +1258,7 @@ export namespace Config { }) const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) { - const auth = yield* Effect.promise(() => Auth.all()) + const auth = yield* authSvc.all().pipe(Effect.orDie) let result: Info = {} for (const [key, value] of Object.entries(auth)) { @@ -1344,17 +1346,20 @@ export namespace Config { log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") } - const active = yield* Effect.promise(() => Account.active()) + const active = Option.getOrUndefined(yield* accountSvc.active().pipe(Effect.orDie)) if (active?.active_org_id) { yield* Effect.gen(function* () { - const [config, token] = yield* Effect.promise(() => - Promise.all([Account.config(active.id, active.active_org_id!), Account.token(active.id)]), + const [configOpt, tokenOpt] = yield* Effect.all( + [accountSvc.config(active.id, active.active_org_id!), accountSvc.token(active.id)], + { concurrency: 2 }, ) + const token = Option.getOrUndefined(tokenOpt) if (token) { process.env["OPENCODE_CONSOLE_TOKEN"] = token Env.set("OPENCODE_CONSOLE_TOKEN", token) } + const config = Option.getOrUndefined(configOpt) if (config) { result = mergeConfigConcatArrays( result, @@ -1365,7 +1370,7 @@ export namespace Config { ) } }).pipe( - Effect.catchDefect((err) => { + Effect.catch((err) => { log.debug("failed to fetch remote account config", { error: err instanceof Error ? err.message : String(err), }) @@ -1502,7 +1507,11 @@ export namespace Config { }), ) - export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) + export const defaultLayer = layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Auth.layer), + Layer.provide(Account.defaultLayer), + ) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/opencode/src/effect/cross-spawn-spawner.ts b/packages/opencode/src/effect/cross-spawn-spawner.ts index f7b8786d08a0..eb2560ff6f7a 100644 --- a/packages/opencode/src/effect/cross-spawn-spawner.ts +++ b/packages/opencode/src/effect/cross-spawn-spawner.ts @@ -1,5 +1,6 @@ import type * as Arr from "effect/Array" -import { NodeSink, NodeStream } from "@effect/platform-node" +import { NodeFileSystem, NodeSink, NodeStream } from "@effect/platform-node" +import * as NodePath from "@effect/platform-node/NodePath" import * as Deferred from "effect/Deferred" import * as Effect from "effect/Effect" import * as Exit from "effect/Exit" @@ -474,3 +475,5 @@ export const layer: Layer.Layer Config.get()) + const cfg = yield* config.get() const cfgIgnores = cfg.watcher?.ignore ?? [] if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { @@ -159,7 +161,9 @@ export namespace FileWatcher { }), ) - const { runPromise } = makeRuntime(Service, layer) + export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) + + const { runPromise } = makeRuntime(Service, defaultLayer) export function init() { return runPromise((svc) => svc.init()) diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index 316ea5ba5c3a..314e8c6e71c8 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -35,12 +35,14 @@ export namespace Format { export const layer = Layer.effect( Service, Effect.gen(function* () { + const config = yield* Config.Service + const state = yield* InstanceState.make( Effect.fn("Format.state")(function* (_ctx) { const enabled: Record = {} const formatters: Record = {} - const cfg = yield* Effect.promise(() => Config.get()) + const cfg = yield* config.get() if (cfg.formatter !== false) { for (const item of Object.values(Formatter)) { @@ -167,7 +169,9 @@ export namespace Format { }), ) - const { runPromise } = makeRuntime(Service, layer) + export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) + + const { runPromise } = makeRuntime(Service, defaultLayer) export async function init() { return runPromise((s) => s.init()) diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 76f3d0c9e169..52c149c4fd9d 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -1,4 +1,3 @@ -import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Effect, Layer, Schema, ServiceMap, Stream } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" @@ -341,9 +340,7 @@ export namespace Installation { export const defaultLayer = layer.pipe( Layer.provide(FetchHttpClient.layer), - Layer.provide(CrossSpawnSpawner.layer), - Layer.provide(NodeFileSystem.layer), - Layer.provide(NodePath.layer), + Layer.provide(CrossSpawnSpawner.defaultLayer), ) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 81a7dfaaca32..de87e568f7ca 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -161,9 +161,11 @@ export namespace LSP { export const layer = Layer.effect( Service, Effect.gen(function* () { + const config = yield* Config.Service + const state = yield* InstanceState.make( Effect.fn("LSP.state")(function* () { - const cfg = yield* Effect.promise(() => Config.get()) + const cfg = yield* config.get() const servers: Record = {} @@ -504,7 +506,9 @@ export namespace LSP { }), ) - const { runPromise } = makeRuntime(Service, layer) + export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) + + const { runPromise } = makeRuntime(Service, defaultLayer) export const init = async () => runPromise((svc) => svc.init()) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 184e84a2a032..15ab0c9e3a0a 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -29,8 +29,6 @@ import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" -import { NodeFileSystem } from "@effect/platform-node" -import * as NodePath from "@effect/platform-node/NodePath" export namespace MCP { const log = Log.create({ service: "mcp" }) @@ -437,6 +435,7 @@ export namespace MCP { log.info("create() successfully created client", { key, toolCount: listed.length }) return { mcpClient, status, defs: listed } satisfies CreateResult }) + const cfgSvc = yield* Config.Service const descendants = Effect.fnUntraced( function* (pid: number) { @@ -478,11 +477,11 @@ export namespace MCP { }) } - const getConfig = () => Effect.promise(() => Config.get()) const cache = yield* InstanceState.make( Effect.fn("MCP.state")(function* () { - const cfg = yield* getConfig() + + const cfg = yield* cfgSvc.get() const config = cfg.mcp ?? {} const s: State = { status: {}, @@ -553,7 +552,8 @@ export namespace MCP { const status = Effect.fn("MCP.status")(function* () { const s = yield* InstanceState.get(cache) - const cfg = yield* getConfig() + + const cfg = yield* cfgSvc.get() const config = cfg.mcp ?? {} const result: Record = {} @@ -613,7 +613,8 @@ export namespace MCP { const tools = Effect.fn("MCP.tools")(function* () { const result: Record = {} const s = yield* InstanceState.get(cache) - const cfg = yield* getConfig() + + const cfg = yield* cfgSvc.get() const config = cfg.mcp ?? {} const defaultTimeout = cfg.experimental?.mcp_timeout @@ -705,7 +706,8 @@ export namespace MCP { }) const getMcpConfig = Effect.fnUntraced(function* (mcpName: string) { - const cfg = yield* getConfig() + + const cfg = yield* cfgSvc.get() const mcpConfig = cfg.mcp?.[mcpName] if (!mcpConfig || !isMcpConfigured(mcpConfig)) return undefined return mcpConfig @@ -876,13 +878,12 @@ export namespace MCP { // --- Per-service runtime --- - const defaultLayer = layer.pipe( + export const defaultLayer = layer.pipe( Layer.provide(McpAuth.layer), Layer.provide(Bus.layer), - Layer.provide(CrossSpawnSpawner.layer), + Layer.provide(Config.defaultLayer), + Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(NodeFileSystem.layer), - Layer.provide(NodePath.layer), ) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index b8de639e763f..f4b8b940d20f 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -111,7 +111,7 @@ export namespace Project { > = Layer.effect( Service, Effect.gen(function* () { - const fsys = yield* AppFileSystem.Service + const fs = yield* AppFileSystem.Service const pathSvc = yield* Path.Path const spawner = yield* ChildProcessSpawner.ChildProcessSpawner @@ -155,7 +155,7 @@ export namespace Project { const scope = yield* Scope.Scope const readCachedProjectId = Effect.fnUntraced(function* (dir: string) { - return yield* fsys.readFileString(pathSvc.join(dir, "opencode")).pipe( + return yield* fs.readFileString(pathSvc.join(dir, "opencode")).pipe( Effect.map((x) => x.trim()), Effect.map(ProjectID.make), Effect.catch(() => Effect.succeed(undefined)), @@ -169,7 +169,7 @@ export namespace Project { type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] } const data: DiscoveryResult = yield* Effect.gen(function* () { - const dotgitMatches = yield* fsys.up({ targets: [".git"], start: directory }).pipe(Effect.orDie) + const dotgitMatches = yield* fs.up({ targets: [".git"], start: directory }).pipe(Effect.orDie) const dotgit = dotgitMatches[0] if (!dotgit) { @@ -222,7 +222,7 @@ export namespace Project { id = roots[0] ? ProjectID.make(roots[0]) : undefined if (id) { - yield* fsys.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore) + yield* fs.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore) } } @@ -270,7 +270,7 @@ export namespace Project { result.sandboxes = yield* Effect.forEach( result.sandboxes, (s) => - fsys.exists(s).pipe( + fs.exists(s).pipe( Effect.orDie, Effect.map((exists) => (exists ? s : undefined)), ), @@ -329,7 +329,7 @@ export namespace Project { if (input.icon?.override) return if (input.icon?.url) return - const matches = yield* fsys + const matches = yield* fs .glob("**/favicon.{ico,png,svg,jpg,jpeg,webp}", { cwd: input.worktree, absolute: true, @@ -339,7 +339,7 @@ export namespace Project { const shortest = matches.sort((a, b) => a.length - b.length)[0] if (!shortest) return - const buffer = yield* fsys.readFile(shortest).pipe(Effect.orDie) + const buffer = yield* fs.readFile(shortest).pipe(Effect.orDie) const base64 = Buffer.from(buffer).toString("base64") const mime = AppFileSystem.mimeType(shortest) const url = `data:${mime};base64,${base64}` @@ -400,7 +400,7 @@ export namespace Project { return yield* Effect.forEach( data.sandboxes, (dir) => - fsys.isDir(dir).pipe( + fs.isDir(dir).pipe( Effect.orDie, Effect.map((ok) => (ok ? dir : undefined)), ), @@ -457,9 +457,8 @@ export namespace Project { ) export const defaultLayer = layer.pipe( - Layer.provide(CrossSpawnSpawner.layer), + Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer), ) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 1ba87126bb10..8ecd8c7a6179 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -273,7 +273,7 @@ export namespace Pty { if (input.size) { session.process.resize(input.size.cols, input.size.rows) } - yield* Effect.promise(() => Bus.publish(Event.Updated, { info: session.info })) + void Bus.publish(Event.Updated, { info: session.info }) return session.info }) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index d6bdf8a3c1d4..4429a25696e0 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -60,24 +60,28 @@ export namespace Snapshot { export class Service extends ServiceMap.Service()("@opencode/Snapshot") {} - export const layer: Layer.Layer = - Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - const locks = new Map() - - const lock = (key: string) => { - const hit = locks.get(key) - if (hit) return hit - - const next = Semaphore.makeUnsafe(1) - locks.set(key, next) - return next - } - - const state = yield* InstanceState.make( + export const layer: Layer.Layer< + Service, + never, + AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner | Config.Service + > = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const config = yield* Config.Service + const locks = new Map() + + const lock = (key: string) => { + const hit = locks.get(key) + if (hit) return hit + + const next = Semaphore.makeUnsafe(1) + locks.set(key, next) + return next + } + + const state = yield* InstanceState.make( Effect.fn("Snapshot.state")(function* (ctx) { const state = { directory: ctx.directory, @@ -123,7 +127,7 @@ export namespace Snapshot { const enabled = Effect.fnUntraced(function* () { if (state.vcs !== "git") return false - return (yield* Effect.promise(() => Config.get())).snapshot !== false + return (yield* config.get()).snapshot !== false }) const excludes = Effect.fnUntraced(function* () { @@ -423,40 +427,39 @@ export namespace Snapshot { }), ) - return Service.of({ - init: Effect.fn("Snapshot.init")(function* () { - yield* InstanceState.get(state) - }), - cleanup: Effect.fn("Snapshot.cleanup")(function* () { - return yield* InstanceState.useEffect(state, (s) => s.cleanup()) - }), - track: Effect.fn("Snapshot.track")(function* () { - return yield* InstanceState.useEffect(state, (s) => s.track()) - }), - patch: Effect.fn("Snapshot.patch")(function* (hash: string) { - return yield* InstanceState.useEffect(state, (s) => s.patch(hash)) - }), - restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) { - return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot)) - }), - revert: Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) { - return yield* InstanceState.useEffect(state, (s) => s.revert(patches)) - }), - diff: Effect.fn("Snapshot.diff")(function* (hash: string) { - return yield* InstanceState.useEffect(state, (s) => s.diff(hash)) - }), - diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) { - return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to)) - }), - }) - }), - ) + return Service.of({ + init: Effect.fn("Snapshot.init")(function* () { + yield* InstanceState.get(state) + }), + cleanup: Effect.fn("Snapshot.cleanup")(function* () { + return yield* InstanceState.useEffect(state, (s) => s.cleanup()) + }), + track: Effect.fn("Snapshot.track")(function* () { + return yield* InstanceState.useEffect(state, (s) => s.track()) + }), + patch: Effect.fn("Snapshot.patch")(function* (hash: string) { + return yield* InstanceState.useEffect(state, (s) => s.patch(hash)) + }), + restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) { + return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot)) + }), + revert: Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) { + return yield* InstanceState.useEffect(state, (s) => s.revert(patches)) + }), + diff: Effect.fn("Snapshot.diff")(function* (hash: string) { + return yield* InstanceState.useEffect(state, (s) => s.diff(hash)) + }), + diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) { + return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to)) + }), + }) + }), + ) export const defaultLayer = layer.pipe( - Layer.provide(CrossSpawnSpawner.layer), + Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(NodeFileSystem.layer), // needed by CrossSpawnSpawner - Layer.provide(NodePath.layer), + Layer.provide(Config.defaultLayer), ) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 3aeee983f44d..0a8ce5ea225f 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -11,9 +11,10 @@ import { Log } from "../util/log" import { Slug } from "@opencode-ai/util/slug" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" -import { Effect, FileSystem, Layer, Path, Scope, ServiceMap, Stream } from "effect" +import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" -import { NodeFileSystem, NodePath } from "@effect/platform-node" +import { NodePath } from "@effect/platform-node" +import { AppFileSystem } from "@/filesystem" import { makeRuntime } from "@/effect/run-service" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" @@ -167,14 +168,15 @@ export namespace Worktree { export const layer: Layer.Layer< Service, never, - FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Project.Service > = Layer.effect( Service, Effect.gen(function* () { const scope = yield* Scope.Scope - const fsys = yield* FileSystem.FileSystem + const fs = yield* AppFileSystem.Service const pathSvc = yield* Path.Path const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const project = yield* Project.Service const git = Effect.fnUntraced( function* (args: string[], opts?: { cwd?: string }) { @@ -201,7 +203,7 @@ export namespace Worktree { const branch = `opencode/${name}` const directory = pathSvc.join(root, name) - if (yield* fsys.exists(directory).pipe(Effect.orDie)) continue + if (yield* fs.exists(directory).pipe(Effect.orDie)) continue const ref = `refs/heads/${branch}` const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: Instance.worktree }) @@ -218,7 +220,7 @@ export namespace Worktree { } const root = pathSvc.join(Global.Path.data, "worktree", Instance.project.id) - yield* fsys.makeDirectory(root, { recursive: true }).pipe(Effect.orDie) + yield* fs.makeDirectory(root, { recursive: true }).pipe(Effect.orDie) const base = name ? slugify(name) : "" return yield* candidate(root, base || undefined) @@ -232,7 +234,7 @@ export namespace Worktree { throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" }) } - yield* Effect.promise(() => Project.addSandbox(Instance.project.id, info.directory).catch(() => undefined)) + yield* project.addSandbox(Instance.project.id, info.directory).pipe(Effect.catch(() => Effect.void)) }) const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) { @@ -297,7 +299,7 @@ export namespace Worktree { const canonical = Effect.fnUntraced(function* (input: string) { const abs = pathSvc.resolve(input) - const real = yield* fsys.realPath(abs).pipe(Effect.catch(() => Effect.succeed(abs))) + const real = yield* fs.realPath(abs).pipe(Effect.catch(() => Effect.succeed(abs))) const normalized = pathSvc.normalize(real) return process.platform === "win32" ? normalized.toLowerCase() : normalized }) @@ -334,7 +336,7 @@ export namespace Worktree { }) function stopFsmonitor(target: string) { - return fsys.exists(target).pipe( + return fs.exists(target).pipe( Effect.orDie, Effect.flatMap((exists) => (exists ? git(["fsmonitor--daemon", "stop"], { cwd: target }) : Effect.void)), ) @@ -364,7 +366,7 @@ export namespace Worktree { const entry = yield* locateWorktree(entries, directory) if (!entry?.path) { - const directoryExists = yield* fsys.exists(directory).pipe(Effect.orDie) + const directoryExists = yield* fs.exists(directory).pipe(Effect.orDie) if (directoryExists) { yield* stopFsmonitor(directory) yield* cleanDirectory(directory) @@ -464,7 +466,7 @@ export namespace Worktree { const target = yield* canonical(pathSvc.resolve(root, entry)) if (target === base) return if (!target.startsWith(`${base}${pathSvc.sep}`)) return - yield* fsys.remove(target, { recursive: true }).pipe(Effect.ignore) + yield* fs.remove(target, { recursive: true }).pipe(Effect.ignore) }), { concurrency: "unbounded" }, ) @@ -603,8 +605,9 @@ export namespace Worktree { ) const defaultLayer = layer.pipe( - Layer.provide(CrossSpawnSpawner.layer), - Layer.provide(NodeFileSystem.layer), + Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(Project.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer), ) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index dc2397b38b51..33f700ebf229 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1,9 +1,19 @@ import { test, expect, describe, mock, afterEach, spyOn } from "bun:test" +import { Effect, Layer, Option } from "effect" +import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Config } from "../../src/config/config" import { Instance } from "../../src/project/instance" import { Auth } from "../../src/auth" import { AccessToken, Account, AccountID, OrgID } from "../../src/account" +import { AppFileSystem } from "../../src/filesystem" +import { provideTmpdirInstance } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" + +/** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */ +const infra = CrossSpawnSpawner.defaultLayer.pipe( + Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), +) import path from "path" import fs from "fs/promises" import { pathToFileURL } from "url" @@ -12,6 +22,14 @@ import { ProjectID } from "../../src/project/schema" import { Filesystem } from "../../src/util/filesystem" import { BunProc } from "../../src/bun" +const emptyAccount = Layer.mock(Account.Service)({ + active: () => Effect.succeed(Option.none()), +}) + +const emptyAuth = Layer.mock(Auth.Service)({ + all: () => Effect.succeed({}), +}) + // Get managed config directory from environment (set in preload.ts) const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! @@ -246,43 +264,44 @@ test("preserves env variables when adding $schema to config", async () => { }) test("resolves env templates in account config with account token", async () => { - const originalActive = Account.active - const originalConfig = Account.config - const originalToken = Account.token const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"] - Account.active = mock(async () => ({ - id: AccountID.make("account-1"), - email: "user@example.com", - url: "https://control.example.com", - active_org_id: OrgID.make("org-1"), - })) - - Account.config = mock(async () => ({ - provider: { - opencode: { - options: { - apiKey: "{env:OPENCODE_CONSOLE_TOKEN}", - }, - }, - }, - })) + const fakeAccount = Layer.mock(Account.Service)({ + active: () => + Effect.succeed( + Option.some({ + id: AccountID.make("account-1"), + email: "user@example.com", + url: "https://control.example.com", + active_org_id: OrgID.make("org-1"), + }), + ), + config: () => + Effect.succeed( + Option.some({ + provider: { opencode: { options: { apiKey: "{env:OPENCODE_CONSOLE_TOKEN}" } } }, + }), + ), + token: () => Effect.succeed(Option.some(AccessToken.make("st_test_token"))), + }) - Account.token = mock(async () => AccessToken.make("st_test_token")) + const layer = Config.layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(emptyAuth), + Layer.provide(fakeAccount), + Layer.provideMerge(infra), + ) try { - await using tmp = await tmpdir() - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await Config.get() - expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token") - }, - }) + await provideTmpdirInstance(() => + Config.Service.use((svc) => + Effect.gen(function* () { + const config = yield* svc.get() + expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token") + }), + ), + ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise) } finally { - Account.active = originalActive - Account.config = originalConfig - Account.token = originalToken if (originalControlToken !== undefined) { process.env["OPENCODE_CONSOLE_TOKEN"] = originalControlToken } else { @@ -1588,7 +1607,7 @@ test("local .opencode config can override MCP from project config", async () => test("project config overrides remote well-known config", async () => { const originalFetch = globalThis.fetch let fetchedUrl: string | undefined - const mockFetch = mock((url: string | URL | Request) => { + globalThis.fetch = mock((url: string | URL | Request) => { const urlStr = url.toString() if (urlStr.includes(".well-known/opencode")) { fetchedUrl = urlStr @@ -1596,13 +1615,7 @@ test("project config overrides remote well-known config", async () => { new Response( JSON.stringify({ config: { - mcp: { - jira: { - type: "remote", - url: "https://jira.example.com/mcp", - enabled: false, - }, - }, + mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: false } }, }, }), { status: 200 }, @@ -1610,60 +1623,46 @@ test("project config overrides remote well-known config", async () => { ) } return originalFetch(url) + }) as unknown as typeof fetch + + const fakeAuth = Layer.mock(Auth.Service)({ + all: () => + Effect.succeed({ + "https://example.com": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }), + }), }) - globalThis.fetch = mockFetch as unknown as typeof fetch - const originalAuthAll = Auth.all - Auth.all = mock(() => - Promise.resolve({ - "https://example.com": { - type: "wellknown" as const, - key: "TEST_TOKEN", - token: "test-token", - }, - }), + const layer = Config.layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(fakeAuth), + Layer.provide(emptyAccount), + Layer.provideMerge(infra), ) try { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - // Project config enables jira (overriding remote default) - await Filesystem.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - mcp: { - jira: { - type: "remote", - url: "https://jira.example.com/mcp", - enabled: true, - }, - }, + await provideTmpdirInstance( + () => + Config.Service.use((svc) => + Effect.gen(function* () { + const config = yield* svc.get() + expect(fetchedUrl).toBe("https://example.com/.well-known/opencode") + expect(config.mcp?.jira?.enabled).toBe(true) }), - ) - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await Config.get() - // Verify fetch was called for wellknown config - expect(fetchedUrl).toBe("https://example.com/.well-known/opencode") - // Project config (enabled: true) should override remote (enabled: false) - expect(config.mcp?.jira?.enabled).toBe(true) + ), + { + git: true, + config: { mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: true } } }, }, - }) + ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise) } finally { globalThis.fetch = originalFetch - Auth.all = originalAuthAll } }) test("wellknown URL with trailing slash is normalized", async () => { const originalFetch = globalThis.fetch let fetchedUrl: string | undefined - const mockFetch = mock((url: string | URL | Request) => { + globalThis.fetch = mock((url: string | URL | Request) => { const urlStr = url.toString() if (urlStr.includes(".well-known/opencode")) { fetchedUrl = urlStr @@ -1671,13 +1670,7 @@ test("wellknown URL with trailing slash is normalized", async () => { new Response( JSON.stringify({ config: { - mcp: { - slack: { - type: "remote", - url: "https://slack.example.com/mcp", - enabled: true, - }, - }, + mcp: { slack: { type: "remote", url: "https://slack.example.com/mcp", enabled: true } }, }, }), { status: 200 }, @@ -1685,43 +1678,35 @@ test("wellknown URL with trailing slash is normalized", async () => { ) } return originalFetch(url) + }) as unknown as typeof fetch + + const fakeAuth = Layer.mock(Auth.Service)({ + all: () => + Effect.succeed({ + "https://example.com/": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }), + }), }) - globalThis.fetch = mockFetch as unknown as typeof fetch - const originalAuthAll = Auth.all - Auth.all = mock(() => - Promise.resolve({ - "https://example.com/": { - type: "wellknown" as const, - key: "TEST_TOKEN", - token: "test-token", - }, - }), + const layer = Config.layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(fakeAuth), + Layer.provide(emptyAccount), + Layer.provideMerge(infra), ) try { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - await Filesystem.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", + await provideTmpdirInstance( + () => + Config.Service.use((svc) => + Effect.gen(function* () { + yield* svc.get() + expect(fetchedUrl).toBe("https://example.com/.well-known/opencode") }), - ) - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await Config.get() - // Trailing slash should be stripped — no double slash in the fetch URL - expect(fetchedUrl).toBe("https://example.com/.well-known/opencode") - }, - }) + ), + { git: true }, + ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise) } finally { globalThis.fetch = originalFetch - Auth.all = originalAuthAll } }) diff --git a/packages/opencode/test/effect/cross-spawn-spawner.test.ts b/packages/opencode/test/effect/cross-spawn-spawner.test.ts index 08cae76e2f6b..287d04ed3188 100644 --- a/packages/opencode/test/effect/cross-spawn-spawner.test.ts +++ b/packages/opencode/test/effect/cross-spawn-spawner.test.ts @@ -9,7 +9,7 @@ import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { tmpdir } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const live = CrossSpawnSpawner.layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer)) +const live = CrossSpawnSpawner.defaultLayer const fx = testEffect(live) function js(code: string, opts?: ChildProcess.CommandOptions) { diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index f98a580f6291..2224a80e680e 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -5,6 +5,7 @@ import path from "path" import { ConfigProvider, Deferred, Effect, Layer, ManagedRuntime, Option } from "effect" import { tmpdir } from "../fixture/fixture" import { Bus } from "../../src/bus" +import { Config } from "../../src/config/config" import { FileWatcher } from "../../src/file/watcher" import { Instance } from "../../src/project/instance" @@ -30,6 +31,7 @@ function withWatcher(directory: string, body: Effect.Effect) { directory, fn: async () => { const layer: Layer.Layer = FileWatcher.layer.pipe( + Layer.provide(Config.defaultLayer), Layer.provide(watcherConfigLayer), ) const rt = ManagedRuntime.make(layer) diff --git a/packages/opencode/test/format/format.test.ts b/packages/opencode/test/format/format.test.ts index c718c13e8b2d..6a9b4f5edac1 100644 --- a/packages/opencode/test/format/format.test.ts +++ b/packages/opencode/test/format/format.test.ts @@ -4,13 +4,14 @@ import { Effect, Layer } from "effect" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { Format } from "../../src/format" +import { Config } from "../../src/config/config" import * as Formatter from "../../src/format/formatter" const node = NodeChildProcessSpawner.layer.pipe( Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), ) -const it = testEffect(Layer.mergeAll(Format.layer, node)) +const it = testEffect(Layer.mergeAll(Format.layer, node).pipe(Layer.provide(Config.defaultLayer))) describe("Format", () => { it.effect("status() returns built-in formatters when no config overrides", () => diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index b030e6cbcd9a..988ae27426c6 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -47,7 +47,7 @@ function mockGitFailure(failArg: string) { }), ) }), - ).pipe(Layer.provide(CrossSpawnSpawner.layer), Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer)) + ).pipe(Layer.provide(CrossSpawnSpawner.defaultLayer)) } function projectLayerWithFailure(failArg: string) {