feat(opencode): add typed application layer graph (#31531)

This commit is contained in:
James Long 2026-06-09 15:31:31 -04:00 committed by GitHub
parent 7a54a2c49c
commit 07e5ea9367
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
68 changed files with 759 additions and 0 deletions

View File

@ -24,6 +24,8 @@ import {
import * as NodeChildProcess from "node:child_process"
import { PassThrough } from "node:stream"
import launch from "cross-spawn"
import { LayerNode } from "./effect/layer-node"
import { filesystem, path } from "./effect/layer-node-platform"
const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err)))
@ -501,5 +503,6 @@ export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSyste
)
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
export const node = LayerNode.make(layer, [filesystem, path])
export * as CrossSpawnSpawner from "./cross-spawn-spawner"

View File

@ -8,6 +8,7 @@ import { Flag } from "../flag/flag"
import { isAbsolute, join } from "path"
import { DatabaseMigration } from "./migration"
import { InstallationChannel } from "../installation/version"
import { LayerNode } from "../effect/layer-node"
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
type DatabaseShape = Effect.Success<typeof makeDatabase>
@ -58,3 +59,5 @@ export const defaultLayer = Layer.unwrap(
return layerFromPath(path())
}),
).pipe(Layer.provide(Global.defaultLayer))
export const node = LayerNode.make(layerFromPath(path()), [])

View File

@ -0,0 +1,12 @@
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
import { FetchHttpClient } from "effect/unstable/http"
import { LayerNode } from "./layer-node"
export const filesystem = LayerNode.make(NodeFileSystem.layer, [])
export const path = LayerNode.make(NodePath.layer, [])
export const httpClient = LayerNode.make(FetchHttpClient.layer, [])
export const requestExecutor = LayerNode.make(RequestExecutor.layer, [httpClient])
export const llmClient = LayerNode.make(LLMClient.layer, [requestExecutor])
export * as LayerNodePlatform from "./layer-node-platform"

View File

@ -0,0 +1,95 @@
import { Layer } from "effect"
type RuntimeLayer = Layer.Layer<never, unknown, unknown>
type AnyNode = Node<unknown, unknown>
type NodeList = readonly [] | readonly [AnyNode, ...AnyNode[]]
type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown> ? A : never
type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E> ? E : never
type Missing<Required, Dependencies extends NodeList> = Exclude<Required, Output<Dependencies[number]>>
type CheckDependencies<Implementation extends Layer.Any, Dependencies extends NodeList> = [
Missing<Layer.Services<Implementation>, Dependencies>,
] extends [never]
? unknown
: { readonly "Missing dependencies": Missing<Layer.Services<Implementation>, Dependencies> }
declare const $OutputType: unique symbol
declare const $ErrorType: unique symbol
export type Node<A, E = never> = {
readonly kind: "layer" | "group"
readonly implementation?: Layer.Any
readonly dependencies: readonly AnyNode[]
readonly [$OutputType]?: () => A
readonly [$ErrorType]?: () => E
}
export function make<const Implementation extends Layer.Any, const Items extends NodeList>(
implementation: Implementation,
dependencies: Items & CheckDependencies<Implementation, NoInfer<Items>>,
): Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>> {
return { kind: "layer", implementation: implementation as Layer.Any, dependencies }
}
export function group<const Items extends NodeList>(
dependencies: Items,
): Node<Output<Items[number]>, Error<Items[number]>> {
return { kind: "group", dependencies }
}
export type Replacement<A = unknown> = {
readonly source: Node<A, unknown>
readonly replacement: Node<A, unknown>
}
type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<ReplacementError, SourceError>] extends [never]
? unknown
: { readonly "New replacement errors": Exclude<ReplacementError, SourceError> }
export function replace<A, E, E2>(
source: Node<A, E>,
replacement: Node<NoInfer<A>, E2> & CheckReplacementErrors<E, NoInfer<E2>>,
): Replacement<A> {
return { source, replacement }
}
export function buildLayer<A, E>(node: Node<A, E>, options?: { readonly replacements?: readonly Replacement[] }) {
const replacements = new Map(options?.replacements?.map((item) => [item.source, item.replacement]))
const cache = new Map<AnyNode, RuntimeLayer>()
const visiting = new Set<AnyNode>()
const stack: AnyNode[] = []
const ids = new Map<AnyNode, number>()
const visit = (input: AnyNode): RuntimeLayer => {
const node = replacements.get(input) ?? input
const cached = cache.get(node)
if (cached) return cached
if (visiting.has(node)) {
const start = stack.indexOf(node)
const cycle = [...stack.slice(start), node].map((item) => `${item.kind}#${ids.get(item)}`).join(" -> ")
throw new Error(`Cycle detected in app graph: ${cycle}`)
}
if (!ids.has(node)) ids.set(node, ids.size + 1)
visiting.add(node)
stack.push(node)
try {
const dependencies = node.dependencies.map(visit)
const nonEmpty = dependencies as [RuntimeLayer, ...RuntimeLayer[]]
const result =
node.kind === "group"
? dependencies.length === 0
? Layer.empty
: Layer.mergeAll(...nonEmpty)
: dependencies.length === 0
? (node.implementation as RuntimeLayer)
: Layer.provide(node.implementation as RuntimeLayer, nonEmpty)
cache.set(node, result)
return result
} finally {
stack.pop()
visiting.delete(node)
}
}
return visit(node) as unknown as Layer.Layer<A, E, never>
}
export * as LayerNode from "./layer-node"

View File

@ -7,6 +7,7 @@ import { EventSequenceTable, EventTable } from "./event/sql"
import { Location } from "./location"
import { externalID, type ExternalID, NonNegativeInt, withStatics } from "./schema"
import { Identifier } from "./util/identifier"
import { LayerNode } from "./effect/layer-node"
import { isDeepStrictEqual } from "node:util"
export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
@ -674,5 +675,6 @@ export const layerWith = (options?: LayerOptions) =>
)
export const layer = layerWith()
export const node = LayerNode.make(layer, [Database.node])
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))

View File

@ -11,6 +11,8 @@ import { CrossSpawnSpawner } from "../cross-spawn-spawner"
import { Global } from "../global"
import { NonNegativeInt } from "../schema"
import { which } from "../util/which"
import { LayerNode } from "../effect/layer-node"
import { httpClient } from "../effect/layer-node-platform"
const VERSION = "15.1.0"
const PLATFORM = {
@ -480,5 +482,6 @@ export const defaultLayer = layer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
)
export const node = LayerNode.make(layer, [FSUtil.node, CrossSpawnSpawner.node, httpClient])
export * as Ripgrep from "./ripgrep"

View File

@ -8,6 +8,7 @@ import { serviceUse } from "../effect/service-use"
import { makeRuntime } from "../effect/runtime"
import { Fff } from "#fff"
import { Ripgrep } from "./ripgrep"
import { LayerNode } from "../effect/layer-node"
const root = path.join(Global.Path.cache, "fff")
@ -539,6 +540,7 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
)
export const node = LayerNode.make(layer, [FSUtil.node, Ripgrep.node])
const { runPromise } = makeRuntime(Service, defaultLayer)

View File

@ -7,6 +7,8 @@ import { Context, Effect, FileSystem, Layer, Schema } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { Glob } from "./util/glob"
import { serviceUse } from "./effect/service-use"
import { LayerNode } from "./effect/layer-node"
import { filesystem } from "./effect/layer-node-platform"
export namespace FSUtil {
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
@ -194,6 +196,7 @@ export namespace FSUtil {
)
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
export const node = LayerNode.make(layer, [filesystem])
// Pure helpers that don't need Effect (path manipulation, sync operations)
export function mimeType(p: string): string {

View File

@ -6,6 +6,7 @@ import { ChildProcess } from "effect/unstable/process"
import { AbsolutePath } from "./schema"
import { FSUtil } from "./fs-util"
import { AppProcess } from "./process"
import { LayerNode } from "./effect/layer-node"
export interface Repo {
/**
@ -400,6 +401,7 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer))
export const node = LayerNode.make(layer, [FSUtil.node, AppProcess.node])
export interface Result {
readonly exitCode: number

View File

@ -5,6 +5,7 @@ import os from "os"
import { Context, Effect, Layer } from "effect"
import { Flock } from "./util/flock"
import { Flag } from "./flag/flag"
import { LayerNode } from "./effect/layer-node"
const app = "opencode"
const data = path.join(xdgData!, app)
@ -76,6 +77,7 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer
export const node = LayerNode.make(layer, [])
export const layerWith = (input: Partial<Interface>) =>
Layer.effect(

View File

@ -8,6 +8,8 @@ import { Hash } from "./util/hash"
import { FSUtil } from "./fs-util"
import { InstallationChannel, InstallationVersion } from "./installation/version"
import { EventV2 } from "./event"
import { LayerNode } from "./effect/layer-node"
import { httpClient } from "./effect/layer-node-platform"
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
export type CatalogModelStatus = typeof CatalogModelStatus.Type
@ -246,5 +248,6 @@ export const defaultLayer = layer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provide(EventV2.defaultLayer),
)
export const node = LayerNode.make(layer, [FSUtil.node, EventV2.node, httpClient])
export * as ModelsDev from "./models-dev"

View File

@ -7,6 +7,8 @@ import { NodeFileSystem } from "@effect/platform-node"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
import { EffectFlock } from "./util/effect-flock"
import { LayerNode } from "./effect/layer-node"
import { filesystem } from "./effect/layer-node-platform"
import { makeRuntime } from "./effect/runtime"
import { NpmConfig } from "./npm-config"
@ -250,6 +252,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(Global.layer),
Layer.provide(NodeFileSystem.layer),
)
export const node = LayerNode.make(layer, [FSUtil.node, Global.node, filesystem, EffectFlock.node])
const { runPromise } = makeRuntime(Service, defaultLayer)

View File

@ -3,6 +3,7 @@ import type { PlatformError } from "effect/PlatformError"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { CrossSpawnSpawner } from "./cross-spawn-spawner"
import { LayerNode } from "./effect/layer-node"
export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()("AppProcessError", {
command: Schema.String,
@ -230,5 +231,6 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
export const node = LayerNode.make(layer, [CrossSpawnSpawner.node])
export * as AppProcess from "./process"

View File

@ -8,6 +8,7 @@ import { AbsolutePath, withStatics } from "./schema"
import { FSUtil } from "./fs-util"
import { Database } from "./database/database"
import { Git } from "./git"
import { LayerNode } from "./effect/layer-node"
import { Hash } from "./util/hash"
import { ProjectDirectoryTable } from "./project/sql"
@ -159,3 +160,4 @@ export const defaultLayer = layer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
)
export const node = LayerNode.make(layer, [Database.node, FSUtil.node, Git.node])

View File

@ -8,6 +8,7 @@ import { FSUtil } from "../fs-util"
import { Git } from "../git"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { LayerNode } from "../effect/layer-node"
import { Project } from "../project"
import { ProjectDirectoryTable } from "./sql"
import { makeStrategies } from "./copy-strategies"
@ -275,3 +276,4 @@ export const defaultLayer = layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(EventV2.defaultLayer),
)
export const node = LayerNode.make(layer, [FSUtil.node, Git.node, EventV2.node, Database.node])

View File

@ -4,6 +4,7 @@ import { WorkspaceV2 } from "../workspace"
import { PositiveInt } from "../schema"
import { PtyID } from "./schema"
import { Cache, Context, Duration, Effect, Layer, Schema } from "effect"
import { LayerNode } from "../effect/layer-node"
const DEFAULT_TTL = Duration.seconds(60)
const CAPACITY = 10_000
@ -56,3 +57,4 @@ export const make = (ttl: Duration.Input = DEFAULT_TTL) =>
export const layer = Layer.effect(Service, make())
export const defaultLayer = layer
export const node = LayerNode.make(layer, [])

View File

@ -4,6 +4,7 @@ import { and, desc, eq, sql } from "drizzle-orm"
import { DateTime, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { LayerNode } from "../effect/layer-node"
import { SessionEvent } from "./event"
import { SessionV1 } from "../v1/session"
import { WorkspaceTable } from "../control-plane/workspace.sql"
@ -447,3 +448,4 @@ export const layer = Layer.effectDiscard(
)
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer))
export const node = LayerNode.make(layer, [EventV2.node, Database.node])

View File

@ -6,6 +6,7 @@ import type { FileSystem, Scope } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { FSUtil } from "../fs-util"
import { Global } from "../global"
import { LayerNode } from "../effect/layer-node"
import { Hash } from "./hash"
export namespace EffectFlock {
@ -280,4 +281,5 @@ export namespace EffectFlock {
)
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.layer))
export const node = LayerNode.make(layer, [Global.node, FSUtil.node])
}

View File

@ -1,3 +1,5 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { httpClient } from "@opencode-ai/core/effect/layer-node-platform"
import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import {
@ -456,4 +458,6 @@ export const layer: Layer.Layer<Service, never, AccountRepo.Service | HttpClient
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.defaultLayer), Layer.provide(FetchHttpClient.layer))
export const node = LayerNode.make(layer, [AccountRepo.node, httpClient])
export * as Account from "./account"

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { eq } from "drizzle-orm"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Effect, Layer, Option, Schema, Context } from "effect"
@ -167,4 +168,6 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
export const node = LayerNode.make(layer, [Database.node])
export * as AccountRepo from "./repo"

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { Config } from "@/config/config"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
@ -430,4 +431,6 @@ export const defaultLayer = layer.pipe(
Layer.provide(Skill.defaultLayer),
)
export const node = LayerNode.make(layer, [Config.node, Auth.node, Plugin.node, Skill.node, Provider.node])
export * as Agent from "./agent"

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import path from "path"
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
import { NonNegativeInt } from "@opencode-ai/core/schema"
@ -93,4 +94,6 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer))
export const node = LayerNode.make(layer, [FSUtil.node])
export * as Auth from "."

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { BackgroundJob as CoreBackgroundJob } from "@opencode-ai/core/background-job"
import { InstanceState } from "@/effect/instance-state"
import { Effect, Layer } from "effect"
@ -33,4 +34,6 @@ export const layer = Layer.effect(
export const defaultLayer = layer
export const node = LayerNode.make(layer, [])
export * as BackgroundJob from "./job"

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { InstanceState } from "@/effect/instance-state"
import { EffectBridge } from "@/effect/bridge"
import type { InstanceContext } from "@/project/instance-context"
@ -178,4 +179,6 @@ export const defaultLayer = layer.pipe(
Layer.provide(Skill.defaultLayer),
)
export const node = LayerNode.make(layer, [Config.node, MCP.node, Skill.node])
export * as Command from "."

View File

@ -1,3 +1,5 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { httpClient } from "@opencode-ai/core/effect/layer-node-platform"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import path from "path"
import { pathToFileURL } from "url"
@ -669,4 +671,6 @@ export const defaultLayer = layer.pipe(
Layer.provide(FetchHttpClient.layer),
)
export const node = LayerNode.make(layer, [FSUtil.node, Auth.node, Account.node, Env.node, Npm.node, httpClient])
export * as Config from "./config"

View File

@ -1,3 +1,5 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { httpClient } from "@opencode-ai/core/effect/layer-node-platform"
import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"
@ -972,4 +974,16 @@ function route(url: string | URL, path: string) {
return next
}
export const node = LayerNode.make(layer, [
Auth.node,
Session.node,
SessionPrompt.node,
httpClient,
EventV2Bridge.node,
Vcs.node,
RuntimeFlags.node,
FSUtil.node,
Database.node,
])
export * as Workspace from "./workspace"

View File

@ -73,4 +73,7 @@ export const layer = (overrides: Partial<Info> = {}) =>
export const defaultLayer = Service.defaultLayer.pipe(Layer.orDie)
export const node = LayerNode.make(defaultLayer, [])
export * as RuntimeFlags from "./runtime-flags"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Context, Effect, Layer } from "effect"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { InstanceState } from "@/effect/instance-state"
@ -37,4 +38,6 @@ export const layer = Layer.effect(
export const defaultLayer = layer
export const node = LayerNode.make(layer, [])
export * as Env from "."

View File

@ -1,5 +1,6 @@
// Opencode publish boundary for core events. Attach routed instance location
// so direct EventV2 consumers can isolate directory/workspace streams.
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { GlobalBus } from "@/bus/global"
import { EventV2 } from "@opencode-ai/core/event"
@ -73,4 +74,6 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer))
export const node = LayerNode.make(layer, [EventV2.node])
export * as EventV2Bridge from "./event-v2-bridge"

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Effect, Layer, Context, Schema } from "effect"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { ChildProcess } from "effect/unstable/process"
@ -199,4 +200,6 @@ export const defaultLayer = layer.pipe(
Layer.provide(RuntimeFlags.defaultLayer),
)
export const node = LayerNode.make(layer, [Config.node, AppProcess.node, RuntimeFlags.node])
export * as Format from "."

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { AppProcess } from "@opencode-ai/core/process"
import { Effect, Layer, Context, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
@ -344,4 +345,6 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(Layer.provide(AppProcess.defaultLayer))
export const node = LayerNode.make(layer, [AppProcess.node])
export * as Git from "."

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Config } from "@/config/config"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import type { MessageV2 } from "@/session/message-v2"
@ -168,4 +169,6 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
export const node = LayerNode.make(layer, [Config.node])
export * as Image from "./image"

View File

@ -1,3 +1,5 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { httpClient } from "@opencode-ai/core/effect/layer-node-platform"
import { Effect, Layer, Schema, Context, Stream } from "effect"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
@ -343,4 +345,6 @@ export const latest = (...args: Parameters<Interface["latest"]>) => runPromise((
export const method = () => runPromise((s) => s.method())
export const upgrade = (...args: Parameters<Interface["upgrade"]>) => runPromise((s) => s.upgrade(...args))
export const node = LayerNode.make(layer, [httpClient, AppProcess.node])
export * as Installation from "."

View File

@ -1,3 +1,5 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
import * as LSPClient from "./client"
@ -504,4 +506,6 @@ export const defaultLayer = layer.pipe(
export * as Diagnostic from "./diagnostic"
export const node = LayerNode.make(layer, [Config.node, RuntimeFlags.node, FSUtil.node, EventV2Bridge.node])
export * as LSP from "./lsp"

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import path from "path"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Global } from "@opencode-ai/core/global"
@ -168,4 +169,6 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(Layer.provide(EffectFlock.defaultLayer), Layer.provide(FSUtil.defaultLayer))
export const node = LayerNode.make(layer, [FSUtil.node, EffectFlock.node])
export * as McpAuth from "./auth"

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { dynamicTool, type Tool, jsonSchema, type JSONSchema7 } from "ai"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
@ -1003,4 +1004,6 @@ export const defaultLayer = layer.pipe(
Layer.provide(FSUtil.defaultLayer),
)
export const node = LayerNode.make(layer, [CrossSpawnSpawner.node, McpAuth.node, EventV2Bridge.node, Config.node])
export * as MCP from "."

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { ConfigPermissionV1 } from "@opencode-ai/core/v1/config/permission"
import { InstanceState } from "@/effect/instance-state"
import { Wildcard } from "@opencode-ai/core/util/wildcard"
@ -224,4 +225,6 @@ export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set<st
export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer))
export const node = LayerNode.make(layer, [EventV2Bridge.node])
export * as Permission from "."

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import type {
Hooks,
PluginInput,
@ -307,4 +308,6 @@ export const defaultLayer = layer.pipe(
Layer.provide(RuntimeFlags.defaultLayer),
)
export const node = LayerNode.make(layer, [EventV2Bridge.node, Config.node, RuntimeFlags.node])
export * as Plugin from "."

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Plugin } from "../plugin"
import { Format } from "../format"
import { LSP } from "@/lsp/lsp"
@ -74,4 +75,16 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
]),
)
export const node = LayerNode.make(layer, [
Config.node,
Format.node,
LSP.node,
Plugin.node,
Project.node,
Search.node,
ShareNext.node,
Snapshot.node,
Vcs.node,
])
export * as InstanceBootstrap from "./bootstrap"

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { GlobalBus } from "@/bus/global"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { WorkspaceContext } from "@/control-plane/workspace-context"
@ -7,6 +8,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect"
import { type InstanceContext } from "./instance-context"
import { InstanceBootstrap } from "./bootstrap-service"
import { InstanceBootstrap as InstanceBootstrapGraph } from "./bootstrap"
import * as Project from "./project"
export interface LoadInput {
@ -202,4 +204,6 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
export const defaultLayer = layer.pipe(Layer.provide(Project.defaultLayer))
export const node = LayerNode.make(layer, [Project.node, InstanceBootstrapGraph.node])
export * as InstanceStore from "./instance-store"

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { and, eq, sql } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
@ -514,4 +515,15 @@ export const defaultLayer = layer.pipe(
export const use = serviceUse(Service)
export const node = LayerNode.make(layer, [
FSUtil.node,
AppProcess.node,
CrossSpawnSpawner.node,
ProjectV2.node,
ProjectCopy.node,
EventV2Bridge.node,
RuntimeFlags.node,
Database.node,
])
export * as Project from "./project"

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Effect, Layer, Context, Schema, Stream, Scope } from "effect"
import { formatPatch, structuredPatch } from "diff"
import { InstanceState } from "@/effect/instance-state"
@ -425,4 +426,6 @@ export const layer: Layer.Layer<Service, never, Git.Service | EventV2Bridge.Serv
export const defaultLayer = layer.pipe(Layer.provide(Git.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer))
export const node = LayerNode.make(layer, [Git.node, EventV2Bridge.node])
export * as Vcs from "./vcs"

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Auth } from "@/auth"
@ -227,4 +228,6 @@ export const defaultLayer = Layer.suspend(() =>
layer.pipe(Layer.provide(Auth.defaultLayer), Layer.provide(Plugin.defaultLayer)),
)
export const node = LayerNode.make(layer, [Auth.node, Plugin.node])
export * as ProviderAuth from "./auth"

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import os from "os"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import fuzzysort from "fuzzysort"
@ -1948,4 +1949,14 @@ export function parseModel(model: string) {
}
}
export const node = LayerNode.make(layer, [
FSUtil.node,
Config.node,
Auth.node,
Env.node,
Plugin.node,
ModelsDev.node,
RuntimeFlags.node,
])
export * as Provider from "./provider"

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Deferred, Effect, Layer, Schema, Context } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { SessionID, MessageID } from "@/session/schema"
@ -223,4 +224,6 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer))
export const node = LayerNode.make(layer, [EventV2Bridge.node])
export * as Question from "."

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { Session } from "./session"
@ -605,4 +606,15 @@ export const defaultLayer = Layer.suspend(() =>
),
)
export const node = LayerNode.make(layer, [
Config.node,
Session.node,
Agent.node,
Plugin.node,
SessionProcessor.node,
Provider.node,
EventV2Bridge.node,
RuntimeFlags.node,
])
export * as SessionCompaction from "./compaction"

View File

@ -1,3 +1,5 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { httpClient } from "@opencode-ai/core/effect/layer-node-platform"
import path from "path"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Effect, Layer, Context } from "effect"
@ -234,4 +236,6 @@ export function loaded(messages: SessionV1.WithParts[]) {
return extract(messages)
}
export const node = LayerNode.make(layer, [Config.node, FSUtil.node, Global.node, RuntimeFlags.node, httpClient])
export * as Instruction from "./instruction"

View File

@ -1,3 +1,5 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { llmClient } from "@opencode-ai/core/effect/layer-node-platform"
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { Provider } from "@/provider/provider"
import { SessionV1 } from "@opencode-ai/core/v1/session"
@ -399,4 +401,15 @@ export const defaultLayer = Layer.suspend(() =>
export const hasToolCalls = LLMRequestPrep.hasToolCalls
export const node = LayerNode.make(layer, [
Auth.node,
Config.node,
Provider.node,
Plugin.node,
Permission.node,
EventV2Bridge.node,
llmClient,
RuntimeFlags.node,
])
export * as LLM from "./llm"

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { Image } from "@/image/image"
import { SessionV1 } from "@opencode-ai/core/v1/session"
@ -1064,4 +1065,20 @@ export const defaultLayer = Layer.suspend(() =>
),
)
export const node = LayerNode.make(layer, [
Session.node,
Config.node,
Snapshot.node,
Agent.node,
LLM.node,
Permission.node,
Plugin.node,
SessionSummary.node,
SessionStatus.node,
Image.node,
EventV2Bridge.node,
RuntimeFlags.node,
Database.node,
])
export * as SessionProcessor from "./processor"

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import path from "path"
import { SessionV1 } from "@opencode-ai/core/v1/session"
@ -1671,4 +1672,33 @@ const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g
export const node = LayerNode.make(layer, [
SessionStatus.node,
Session.node,
Agent.node,
Provider.node,
SessionProcessor.node,
SessionCompaction.node,
Plugin.node,
Command.node,
Config.node,
Permission.node,
FSUtil.node,
MCP.node,
LSP.node,
ToolRegistry.node,
Truncate.node,
Image.node,
CrossSpawnSpawner.node,
Instruction.node,
SessionRunState.node,
SessionRevert.node,
SessionSummary.node,
SystemPrompt.node,
LLM.node,
EventV2Bridge.node,
RuntimeFlags.node,
Database.node,
])
export * as SessionPrompt from "./prompt"

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Effect, Layer, Context, Schema } from "effect"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { EventV2Bridge } from "@/event-v2-bridge"
@ -147,4 +148,13 @@ export const defaultLayer = Layer.suspend(() =>
),
)
export const node = LayerNode.make(layer, [
Session.node,
Snapshot.node,
Storage.node,
EventV2Bridge.node,
SessionSummary.node,
SessionRunState.node,
])
export * as SessionRevert from "./revert"

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { InstanceState } from "@/effect/instance-state"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Runner } from "@/effect/runner"
@ -150,4 +151,6 @@ function busyError(sessionID: SessionID) {
return new Session.BusyError({ sessionID })
}
export const node = LayerNode.make(layer, [BackgroundJob.node, SessionStatus.node])
export * as SessionRunState from "./run-state"

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { Slug } from "@opencode-ai/core/util/slug"
import { SessionV1 } from "@opencode-ai/core/v1/session"
@ -1113,4 +1114,6 @@ export function* listGlobal(input?: {
}
}
export const node = LayerNode.make(layer, [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node])
export * as Session from "./session"

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { InstanceState } from "@/effect/instance-state"
import { SessionID } from "./schema"
import { NonNegativeInt } from "@opencode-ai/core/schema"
@ -91,4 +92,6 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer))
export const node = LayerNode.make(layer, [EventV2Bridge.node])
export * as SessionStatus from "./status"

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Effect, Layer, Context, Schema } from "effect"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { EventV2Bridge } from "@/event-v2-bridge"
@ -159,4 +160,6 @@ export const DiffInput = Schema.Struct({
})
export type DiffInput = Schema.Schema.Type<typeof DiffInput>
export const node = LayerNode.make(layer, [Session.node, Snapshot.node, EventV2Bridge.node, Config.node])
export * as SessionSummary from "./summary"

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Context, Effect, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state"
@ -81,4 +82,6 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(Layer.provide(Skill.defaultLayer))
export const node = LayerNode.make(layer, [Skill.node])
export * as SystemPrompt from "./system"

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { SessionID } from "./schema"
import { Effect, Layer, Context, Schema } from "effect"
import { Database } from "@opencode-ai/core/database/database"
@ -84,4 +85,6 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(Database.defaultLayer))
export const node = LayerNode.make(layer, [EventV2Bridge.node, Database.node])
export * as Todo from "./todo"

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Session } from "@/session/session"
import { SessionID } from "@/session/schema"
import { Effect, Layer, Scope, Context } from "effect"
@ -55,4 +56,6 @@ export const defaultLayer = layer.pipe(
Layer.provide(RuntimeFlags.defaultLayer),
)
export const node = LayerNode.make(layer, [Config.node, Session.node, ShareNext.node, RuntimeFlags.node])
export * as SessionShare from "./session"

View File

@ -1,3 +1,5 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { httpClient } from "@opencode-ai/core/effect/layer-node-platform"
import type * as SDK from "@opencode-ai/sdk/v2"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Effect, Exit, Layer, Option, Schema, Scope, Context, Stream } from "effect"
@ -370,4 +372,14 @@ export const defaultLayer = layer.pipe(
Layer.provide(Session.defaultLayer),
)
export const node = LayerNode.make(layer, [
Account.node,
EventV2Bridge.node,
Config.node,
Database.node,
httpClient,
Provider.node,
Session.node,
])
export * as ShareNext from "./share-next"

View File

@ -1,3 +1,5 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { httpClient, path } from "@opencode-ai/core/effect/layer-node-platform"
import { NodePath } from "@effect/platform-node"
import { Effect, Layer, Path, Schema, Context } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
@ -102,4 +104,6 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(NodePath.layer),
)
export const node = LayerNode.make(layer, [FSUtil.node, path, httpClient])
export * as Discovery from "./discovery"

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import path from "path"
import { pathToFileURL } from "url"
import { Effect, Layer, Context, Schema } from "effect"
@ -353,4 +354,13 @@ export function fmt(list: Info[], opts: { verbose: boolean }) {
].join("\n")
}
export const node = LayerNode.make(layer, [
Discovery.node,
Config.node,
EventV2Bridge.node,
FSUtil.node,
Global.node,
RuntimeFlags.node,
])
export * as Skill from "."

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Cause, Duration, Effect, Layer, Schedule, Schema, Semaphore, Context } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { formatPatch, structuredPatch } from "diff"
@ -756,4 +757,6 @@ export const defaultLayer = layer.pipe(
Layer.provide(Config.defaultLayer),
)
export const node = LayerNode.make(layer, [FSUtil.node, AppProcess.node, Config.node])
export * as Snapshot from "."

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import path from "path"
import { Global } from "@opencode-ai/core/global"
import { FSUtil } from "@opencode-ai/core/fs-util"
@ -323,4 +324,6 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer))
export const node = LayerNode.make(layer, [FSUtil.node, Git.node])
export * as Storage from "./storage"

View File

@ -1,3 +1,6 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { httpClient } from "@opencode-ai/core/effect/layer-node-platform"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { PlanExitTool } from "./plan"
import { Session } from "@/session/session"
import { QuestionTool } from "./question"
@ -437,4 +440,28 @@ function isJsonSchemaObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
export const node = LayerNode.make(layer, [
Config.node,
Plugin.node,
Question.node,
Todo.node,
Agent.node,
Skill.node,
Session.node,
BackgroundJob.node,
Provider.node,
LSP.node,
Instruction.node,
FSUtil.node,
EventV2Bridge.node,
httpClient,
CrossSpawnSpawner.node,
Ripgrep.node,
Search.node,
Format.node,
Truncate.node,
RuntimeFlags.node,
Database.node,
])
export * as ToolRegistry from "./registry"

View File

@ -1,3 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, Layer, Option, Schedule, Context } from "effect"
import path from "path"
@ -152,4 +153,6 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(NodePath.layer))
export const node = LayerNode.make(layer, [FSUtil.node])
export * as Truncate from "./truncate"

View File

@ -1,3 +1,5 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { path } from "@opencode-ai/core/effect/layer-node-platform"
import { Global } from "@opencode-ai/core/global"
import { InstanceLayer } from "@/project/instance-layer"
import { InstanceStore } from "@/project/instance-store"
@ -639,4 +641,14 @@ export const appLayer = layer.pipe(
export const defaultLayer = appLayer.pipe(Layer.provide(InstanceLayer.layer))
export const node = LayerNode.make(layer, [
FSUtil.node,
path,
AppProcess.node,
Git.node,
Project.node,
InstanceStore.node,
Database.node,
])
export * as Worktree from "."

View File

@ -0,0 +1,101 @@
import { test } from "bun:test"
import { Context, Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
class A extends Context.Service<A, { readonly value: "a" }>()("test/A") {}
class B extends Context.Service<B, { readonly value: "b" }>()("test/B") {}
class C extends Context.Service<C, { readonly value: "c" }>()("test/C") {}
class LayerError {
readonly _tag = "LayerError"
}
class NotFoundError {
readonly _tag = "NotFoundError"
}
class DiskError {
readonly _tag = "DiskError"
}
class NetworkError {
readonly _tag = "NetworkError"
}
const aImplementation = Layer.succeed(A, A.of({ value: "a" }))
const bImplementation = Layer.effect(B, Effect.gen(function* () {
yield* A
return B.of({ value: "b" })
}))
const cImplementation = Layer.effect(C, Effect.gen(function* () {
yield* A
yield* B
return C.of({ value: "c" })
}))
const failingAImplementation = Layer.effect(A, Effect.fail(new LayerError()))
const notFoundAImplementation = Layer.effect(A, Effect.fail(new NotFoundError()))
const diskAImplementation = Layer.effect(A, Effect.fail(new DiskError()))
const networkAImplementation = Layer.effect(A, Effect.fail(new NetworkError()))
const notFoundOrDiskAImplementation = Layer.effect(
A,
Effect.fail(new NotFoundError() as NotFoundError | DiskError),
)
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false
type Assert<T extends true> = T
type AProvides = Assert<Equal<Layer.Success<typeof aImplementation>, A>>
type ARequires = Assert<Equal<Layer.Services<typeof aImplementation>, never>>
type BProvides = Assert<Equal<Layer.Success<typeof bImplementation>, B>>
type BRequires = Assert<Equal<Layer.Services<typeof bImplementation>, A>>
type CRequires = Assert<Equal<Layer.Services<typeof cImplementation>, A | B>>
void (0 as unknown as AProvides)
void (0 as unknown as ARequires)
void (0 as unknown as BProvides)
void (0 as unknown as BRequires)
void (0 as unknown as CRequires)
const a = LayerNode.make(aImplementation, [])
const b = LayerNode.make(bImplementation, [a])
const c = LayerNode.make(cImplementation, [a, b])
const failingA = LayerNode.make(failingAImplementation, [])
const bWithFailingA = LayerNode.make(bImplementation, [failingA])
const notFoundA = LayerNode.make(notFoundAImplementation, [])
const diskA = LayerNode.make(diskAImplementation, [])
const networkA = LayerNode.make(networkAImplementation, [])
const notFoundOrDiskA = LayerNode.make(notFoundOrDiskAImplementation, [])
// @ts-expect-error B requires A
LayerNode.make(bImplementation, [])
// @ts-expect-error C requires both A and B
LayerNode.make(cImplementation, [a])
type ANodeProvides = Assert<Equal<typeof a, LayerNode.Node<A, never>>>
type BNodeProvides = Assert<Equal<typeof b, LayerNode.Node<B, never>>>
type CNodeProvides = Assert<Equal<typeof c, LayerNode.Node<C, never>>>
type FailingANodeError = Assert<Equal<typeof failingA, LayerNode.Node<A, LayerError>>>
type DependentNodeError = Assert<Equal<typeof bWithFailingA, LayerNode.Node<B, LayerError>>>
void (0 as unknown as ANodeProvides)
void (0 as unknown as BNodeProvides)
void (0 as unknown as CNodeProvides)
void (0 as unknown as FailingANodeError)
void (0 as unknown as DependentNodeError)
const closed = LayerNode.buildLayer(c)
const closedWithError = LayerNode.buildLayer(bWithFailingA)
type ClosedProvides = Assert<Equal<Layer.Success<typeof closed>, C>>
type ClosedRequires = Assert<Equal<Layer.Services<typeof closed>, never>>
type ClosedError = Assert<Equal<Layer.Error<typeof closedWithError>, LayerError>>
void (0 as unknown as ClosedProvides)
void (0 as unknown as ClosedRequires)
void (0 as unknown as ClosedError)
const replacement = LayerNode.make(Layer.succeed(A, A.of({ value: "a" })), [])
LayerNode.replace(a, replacement)
LayerNode.replace(notFoundOrDiskA, notFoundA)
LayerNode.replace(notFoundOrDiskA, diskA)
// @ts-expect-error An override for A must still provide A
LayerNode.replace(a, b)
// @ts-expect-error A replacement cannot introduce NetworkError
LayerNode.replace(notFoundOrDiskA, networkA)
test("type exploration compiles", () => {})

View File

@ -0,0 +1,208 @@
import { describe, expect, test } from "bun:test"
import { Cause, Context, Effect, Exit, Layer } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
const { buildLayer: build, group, replace } = LayerNode
const node = LayerNode.make
class Value extends Context.Service<Value, { readonly value: string }>()("test/Value") {}
class Greeting extends Context.Service<Greeting, { readonly text: string }>()("test/Greeting") {}
const value = LayerNode.make(Layer.succeed(Value, Value.of({ value: "production" })), [])
const greetingImplementation = Layer.effect(
Greeting,
Effect.gen(function* () {
return Greeting.of({ text: `hello ${(yield* Value).value}` })
}),
)
const greeting = LayerNode.make(greetingImplementation, [value])
// @ts-expect-error Greeting requires Value
LayerNode.make(greetingImplementation, [])
describe("app graph", () => {
test("creates any selected dependency layer", async () => {
const result = Effect.gen(function* () {
return (yield* Greeting).text
}).pipe(Effect.provide(build(greeting)))
expect(await Effect.runPromise(result)).toBe("hello production")
})
test("applies overrides before dependency materialization", async () => {
const replacement = node(Layer.succeed(Value, Value.of({ value: "simulation" })), [])
const graph = build(greeting, { replacements: [replace(value, replacement)] })
const result = Effect.gen(function* () {
return (yield* Greeting).text
}).pipe(Effect.provide(graph))
expect(await Effect.runPromise(result)).toBe("hello simulation")
})
test("acquires a shared dependency once", async () => {
class Shared extends Context.Service<Shared, { readonly value: string }>()("test/Shared") {}
class Left extends Context.Service<Left, { readonly value: string }>()("test/Left") {}
class Right extends Context.Service<Right, { readonly value: string }>()("test/Right") {}
let acquisitions = 0
const shared = node(
Layer.effect(
Shared,
Effect.sync(() => {
acquisitions++
return Shared.of({ value: "shared" })
}),
),
[],
)
const left = node(
Layer.effect(
Left,
Effect.gen(function* () {
return Left.of({ value: `${(yield* Shared).value}-left` })
}),
),
[shared],
)
const right = node(
Layer.effect(
Right,
Effect.gen(function* () {
return Right.of({ value: `${(yield* Shared).value}-right` })
}),
),
[shared],
)
const result = Effect.gen(function* () {
return [(yield* Left).value, (yield* Right).value]
}).pipe(Effect.provide(build(group([left, right]))))
expect(await Effect.runPromise(result)).toEqual(["shared-left", "shared-right"])
expect(acquisitions).toBe(1)
})
test("applies a replacement to every transitive consumer", async () => {
class Left extends Context.Service<Left, { readonly value: string }>()("test/ReplacementLeft") {}
class Right extends Context.Service<Right, { readonly value: string }>()("test/ReplacementRight") {}
const left = node(
Layer.effect(
Left,
Effect.gen(function* () {
return Left.of({ value: (yield* Value).value })
}),
),
[value],
)
const right = node(
Layer.effect(
Right,
Effect.gen(function* () {
return Right.of({ value: (yield* Value).value })
}),
),
[value],
)
const replacement = node(Layer.succeed(Value, Value.of({ value: "simulation" })), [])
const graph = build(group([left, right]), { replacements: [replace(value, replacement)] })
const result = Effect.gen(function* () {
return [(yield* Left).value, (yield* Right).value]
}).pipe(Effect.provide(graph))
expect(await Effect.runPromise(result)).toEqual(["simulation", "simulation"])
})
test("propagates layer acquisition errors", async () => {
class AcquisitionError {
readonly _tag = "AcquisitionError"
}
const failing = node(Layer.effect(Value, Effect.fail(new AcquisitionError())), [])
const exit = await Effect.runPromiseExit(Effect.provide(Value, build(failing)))
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(AcquisitionError)
})
test("groups expose every selected service", async () => {
class Count extends Context.Service<Count, { readonly value: number }>()("test/Count") {}
const count = node(Layer.succeed(Count, Count.of({ value: 3 })), [])
const result = Effect.gen(function* () {
return { text: (yield* Value).value, count: (yield* Count).value }
}).pipe(Effect.provide(build(group([value, count]))))
expect(await Effect.runPromise(result)).toEqual({ text: "production", count: 3 })
})
test("builds an empty group", async () => {
expect(await Effect.runPromise(Effect.succeed("ok").pipe(Effect.provide(build(group([])))))).toBe("ok")
})
test("builds replacements with their own dependencies", async () => {
class ReplacementConfig extends Context.Service<ReplacementConfig, { readonly value: string }>()(
"test/ReplacementConfig",
) {}
const replacementConfig = node(
Layer.succeed(ReplacementConfig, ReplacementConfig.of({ value: "replacement" })),
[],
)
const replacement = node(
Layer.effect(
Value,
Effect.gen(function* () {
return Value.of({ value: (yield* ReplacementConfig).value })
}),
),
[replacementConfig],
)
const result = Effect.gen(function* () {
return (yield* Greeting).text
}).pipe(Effect.provide(build(greeting, { replacements: [replace(value, replacement)] })))
expect(await Effect.runPromise(result)).toBe("hello replacement")
})
test("does not acquire unreachable replacements", async () => {
let acquisitions = 0
const unreachable = node(Layer.succeed(Value, Value.of({ value: "unreachable" })), [])
const replacement = node(
Layer.effect(
Value,
Effect.sync(() => {
acquisitions++
return Value.of({ value: "replacement" })
}),
),
[],
)
await Effect.runPromise(Effect.provide(Greeting, build(greeting, { replacements: [replace(unreachable, replacement)] })))
expect(acquisitions).toBe(0)
})
test("rejects a direct cycle", () => {
const cyclic = node(Layer.succeed(Value, Value.of({ value: "cyclic" })), [])
;(cyclic.dependencies as LayerNode.Node<unknown, unknown>[]).push(cyclic)
expect(() => build(cyclic)).toThrow("Cycle detected in app graph: layer#1 -> layer#1")
})
test("rejects an indirect cycle", () => {
const first = node(Layer.succeed(Value, Value.of({ value: "first" })), [])
const second = node(Layer.succeed(Value, Value.of({ value: "second" })), [first])
const third = node(Layer.succeed(Value, Value.of({ value: "third" })), [second])
;(first.dependencies as LayerNode.Node<unknown, unknown>[]).push(third)
expect(() => build(first)).toThrow("Cycle detected in app graph: layer#1 -> layer#2 -> layer#3 -> layer#1")
})
test("rejects a cycle introduced by a replacement", () => {
const replacement = node(Layer.succeed(Value, Value.of({ value: "replacement" })), [])
const consumer = node(greetingImplementation, [value])
;(replacement.dependencies as LayerNode.Node<unknown, unknown>[]).push(consumer)
expect(() => build(consumer, { replacements: [replace(value, replacement)] })).toThrow(
"Cycle detected in app graph: layer#1 -> layer#2 -> layer#1",
)
})
})