effect: introduce defineService and drop per-call Effect.provide

Wave A migrated TuiConfig/Installation callers to
`AppRuntime.runPromise(svc.use(fn).pipe(Effect.provide(svc.layer)))`,
which couples every call site to the service's layer construction.

`defineService(Service, layer)` composes `makeRuntime` on top of an
existing service so the service exposes its own runPromise/runFork/etc.
Each per-service runtime shares the global memoMap, so dependencies are
still built once.

Call sites collapse from:
  await AppRuntime.runPromise(
    TuiConfig.Service.use((svc) => svc.get()).pipe(Effect.provide(TuiConfig.layer)),
  )
to:
  await TuiConfig.runPromise((svc) => svc.get())

Same shape applied to Installation. No per-method async wrappers.
This commit is contained in:
Kit Langton
2026-05-12 19:32:23 -04:00
parent 9f290d4381
commit 9e26c3a00d
9 changed files with 34 additions and 34 deletions

View File

@@ -9,7 +9,6 @@ import { Context, Effect, Layer } from "effect"
import { stringifyKeyStroke } from "@opentui/keymap"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { TuiKeybind } from "@/cli/cmd/tui/config/keybind"
import { AppRuntime } from "@/effect/app-runtime"
import { makeRuntime } from "@/effect/run-service"
import { reusePendingTask } from "./runtime.shared"
import { resolveSession, sessionHistory } from "./session.shared"
@@ -62,9 +61,9 @@ const configTask: { current?: Promise<Config> } = {}
class Service extends Context.Service<Service, BootService>()("@opencode/RunBoot") {}
// Exposed on `TuiConfigLoader` so tests can mock without depending on the
// TuiConfig namespace; production calls TuiConfig.Service via AppRuntime.
// TuiConfig namespace; production calls the TuiConfig per-service runtime.
export const TuiConfigLoader = {
get: () => AppRuntime.runPromise(TuiConfig.Service.use((svc) => svc.get()).pipe(Effect.provide(TuiConfig.layer))),
get: () => TuiConfig.runPromise((svc) => svc.get()),
}
function loadConfig() {

View File

@@ -2,8 +2,6 @@ import { cmd } from "../cmd"
import { UI } from "@/cli/ui"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { AppRuntime } from "@/effect/app-runtime"
import { Effect } from "effect"
import { errorMessage } from "@/util/error"
import { validateSession } from "./validate-session"
import { ServerAuth } from "@/server/auth"
@@ -68,9 +66,7 @@ export const AttachCommand = cmd({
}
})()
const headers = ServerAuth.headers({ password: args.password, username: args.username })
const config = await AppRuntime.runPromise(
TuiConfig.Service.use((svc) => svc.get()).pipe(Effect.provide(TuiConfig.layer)),
)
const config = await TuiConfig.runPromise((svc) => svc.get())
const { tui } = await import("./app")
try {

View File

@@ -14,6 +14,7 @@ import { Global } from "@opencode-ai/core/global"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { CurrentWorkingDirectory } from "./cwd"
import { ConfigPlugin } from "@/config/plugin"
import { defineService } from "@/effect/run-service"
import { TuiKeybind } from "./keybind"
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
import { Filesystem } from "@/util/filesystem"
@@ -244,3 +245,5 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(AppFileSystem.defaultLayer))
export const { runPromise, runFork, runCallback } = defineService(Service, defaultLayer)

View File

@@ -14,8 +14,6 @@ import {
import path from "path"
import { fileURLToPath } from "url"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { AppRuntime } from "@/effect/app-runtime"
import { Effect } from "effect"
import * as Log from "@opencode-ai/core/util/log"
import { errorData, errorMessage } from "@/util/error"
import { isRecord } from "@/util/record"
@@ -45,12 +43,9 @@ import { createCommandShim } from "./command-shim"
ensureRuntimePluginSupport({ additional: keymapRuntimeModules })
// Exposed as a separate function so tests can mock it without touching the
// TuiConfig service. Production callers wait on plugin install fibers via
// AppRuntime + TuiConfig.layer; tests replace this with a no-op.
export const waitForDependencies = () =>
AppRuntime.runPromise(
TuiConfig.Service.use((svc) => svc.waitForDependencies()).pipe(Effect.provide(TuiConfig.layer)),
)
// TuiConfig service. Production callers wait on plugin install fibers via the
// TuiConfig per-service runtime; tests replace this with a no-op.
export const waitForDependencies = () => TuiConfig.runPromise((svc) => svc.waitForDependencies())
type PluginLoad = {
options: ConfigPlugin.Options | undefined

View File

@@ -14,8 +14,6 @@ import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { writeHeapSnapshot } from "v8"
import { TuiConfig } from "./config/tui"
import { AppRuntime } from "@/effect/app-runtime"
import { Effect } from "effect"
import {
OPENCODE_PROCESS_ROLE,
OPENCODE_RUN_ID,
@@ -189,9 +187,7 @@ export const TuiThreadCommand = cmd({
}
const prompt = await input(args.prompt)
const config = await AppRuntime.runPromise(
TuiConfig.Service.use((svc) => svc.get()).pipe(Effect.provide(TuiConfig.layer)),
)
const config = await TuiConfig.runPromise((svc) => svc.get())
const network = resolveNetworkOptionsNoConfig(args)
const external =

View File

@@ -2,7 +2,6 @@ import type { Argv } from "yargs"
import { UI } from "../ui"
import * as prompts from "@clack/prompts"
import { Installation } from "../../installation"
import { AppRuntime } from "../../effect/app-runtime"
import { Global } from "@opencode-ai/core/global"
import fs from "fs/promises"
import path from "path"
@@ -58,7 +57,7 @@ export const UninstallCommand = {
UI.empty()
prompts.intro("Uninstall OpenCode")
const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
const method = await Installation.runPromise((svc) => svc.method())
prompts.log.info(`Installation method: ${method}`)
const targets = await collectRemovalTargets(args, method)

View File

@@ -2,7 +2,6 @@ import type { Argv } from "yargs"
import { UI } from "../ui"
import * as prompts from "@clack/prompts"
import { Installation } from "../../installation"
import { AppRuntime } from "../../effect/app-runtime"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Effect } from "effect"
@@ -27,7 +26,7 @@ export const UpgradeCommand = {
UI.println(UI.logo(" "))
UI.empty()
prompts.intro("Upgrade")
const detectedMethod = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
const detectedMethod = await Installation.runPromise((svc) => svc.method())
const method = (args.method as Installation.Method) ?? detectedMethod
if (method === "unknown") {
prompts.log.error(`opencode is installed to ${process.execPath} and may be managed by a package manager`)
@@ -47,7 +46,7 @@ export const UpgradeCommand = {
prompts.log.info("Using method: " + method)
const target = args.target
? args.target.replace(/^v/, "")
: await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest()))
: await Installation.runPromise((svc) => svc.latest())
if (InstallationVersion === target) {
prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`)
@@ -58,14 +57,11 @@ export const UpgradeCommand = {
prompts.log.info(`From ${InstallationVersion}${target}`)
const spinner = prompts.spinner()
spinner.start("Upgrading...")
const err = await AppRuntime.runPromise(
Effect.gen(function* () {
const installation = yield* Installation.Service
return yield* installation.upgrade(method, target).pipe(
Effect.map(() => undefined as Installation.UpgradeFailedError | Error | undefined),
Effect.catch((error) => Effect.succeed(error as Installation.UpgradeFailedError | Error | undefined)),
)
}),
const err = await Installation.runPromise((installation) =>
installation.upgrade(method, target).pipe(
Effect.map(() => undefined as Installation.UpgradeFailedError | Error | undefined),
Effect.catch((error) => Effect.succeed(error as Installation.UpgradeFailedError | Error | undefined)),
),
).catch((err: Error) => err)
if (err) {
spinner.stop("Upgrade failed", 1)

View File

@@ -55,3 +55,16 @@ export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Laye
getRuntime().runCallback(attach(service.use(fn))),
}
}
/**
* Composition of `makeRuntime` for services that own their own runtime. Returns
* the service class, the supplied layer, and the per-service runtime methods
* (`runPromise`, `runFork`, etc.). Each runtime uses the shared `memoMap`, so
* dependencies are built once across all `defineService` runtimes.
*
* Use for services that don't belong in `AppLayer` (TUI-specific config, etc.)
* or for services where call sites want to bypass `AppRuntime` provisioning.
*/
export function defineService<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
return { Service: service, layer, ...makeRuntime(service, layer) }
}

View File

@@ -10,6 +10,7 @@ import * as Log from "@opencode-ai/core/util/log"
import semver from "semver"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import { NpmConfig } from "@opencode-ai/core/npm-config"
import { defineService } from "@/effect/run-service"
const log = Log.create({ service: "installation" })
@@ -324,4 +325,6 @@ export const defaultLayer = layer.pipe(
Layer.provide(CrossSpawnSpawner.defaultLayer),
)
export const { runPromise, runFork, runCallback } = defineService(Service, defaultLayer)
export * as Installation from "."