refactor(core): refine layer node replacements (#34377)

This commit is contained in:
James Long 2026-06-28 23:48:18 -04:00 committed by GitHub
commit 84336e4f91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 346 additions and 341 deletions

View file

@ -1,24 +1,23 @@
import { Layer } from "effect"
import { buildLocationServiceMap } from "../location-services" import { buildLocationServiceMap } from "../location-services"
import { LocationServiceMap } from "../location-service-map" import { LocationServiceMap } from "../location-service-map"
import { LayerNode } from "./layer-node" import { LayerNode } from "./layer-node"
import { makeGlobalNode } from "./app-node" import { makeGlobalNode } from "./app-node"
export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements?: readonly LayerNode.Replacement[]) { export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements: LayerNode.Replacements = []) {
const replacementMap = new Map(replacements?.map((item) => [item.source, item.replacement])) let allReplacements = replacements
if (!LayerNode.hasUnbound(root, LocationServiceMap.node)) { // Only build the location service map if it's actually needed
// If the location service map is not needed, we shouldn't pull it if (LayerNode.hasUnbound(root, LocationServiceMap.node) && !hasReplacement(replacements, LocationServiceMap.node)) {
// in. Compile the graph normally const locationMap = buildLocationServiceMap(replacements)
return LayerNode.compile(root, replacementMap) const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
allReplacements = replacements.concat([[LocationServiceMap.node, locationMapNode]])
} }
const locationMap = buildLocationServiceMap(replacementMap) return LayerNode.compile(root, allReplacements)
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] }) }
const app = LayerNode.bind(root, LocationServiceMap.node, locationMapNode) function hasReplacement(replacements: LayerNode.Replacements, node: LayerNode.Node<unknown, unknown, any>) {
return replacements.some(([source]) => source.name === node.name)
return LayerNode.compile(app, replacementMap)
} }
export * as AppNodeBuilder from "./app-node-builder" export * as AppNodeBuilder from "./app-node-builder"

View file

@ -0,0 +1 @@
File to save in: ~/.local/share/opencode/worktree/012780/location-layer-tiers/packages/core/src/effect/

View file

@ -111,20 +111,47 @@ export function group<const Items extends readonly AnyNode[]>(
return { kind: "group", name: "group", dependencies } return { kind: "group", name: "group", dependencies }
} }
export type Replacement = { export type Replacement = readonly [source: AnyNode, replacement: AnyNode | Layer.Any]
readonly source: Layer.Any export type Replacements = readonly Replacement[]
readonly replacement: Layer.Any
}
type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<ReplacementError, SourceError>] extends [never] type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<ReplacementError, SourceError>] extends [never]
? unknown ? unknown
: { readonly "New replacement errors": Exclude<ReplacementError, SourceError> } : { readonly "New replacement errors": Exclude<ReplacementError, SourceError> }
export function replace<A, E, R, E2>( type CheckReplacement<Item> = Item extends readonly [Node<infer A, infer E, infer T>, infer Replacement]
source: Layer.Layer<A, E, R>, ? Replacement extends Node<NoInfer<A>, infer E2, T>
replacement: Layer.Layer<NoInfer<A>, E2, never> & CheckReplacementErrors<E, NoInfer<E2>>, ? CheckReplacementErrors<E, NoInfer<E2>>
): Replacement { : Replacement extends Layer.Layer<NoInfer<A>, infer E2, never>
return { source, replacement } ? CheckReplacementErrors<E, NoInfer<E2>>
: { readonly "Invalid replacement": Replacement }
: { readonly "Invalid replacement": Item }
type CheckReplacements<Items extends Replacements> = {
readonly [K in keyof Items]: CheckReplacement<Items[K]>
}
type ValidReplacements<Items extends Replacements> = Items & CheckReplacements<Items>
function replacementNode(source: AnyNode, replacement: AnyNode | Layer.Any) {
const replacementNode = isNode(replacement)
? replacement
: make({ ...nodeMakeIdentity(source), layer: replacement as Layer.Layer<unknown, unknown>, deps: [], tag: source.tag })
if (source.name !== replacementNode.name) {
throw new Error(`Cannot replace ${source.name} with ${replacementNode.name}`)
}
if (source.tag !== replacementNode.tag) {
throw new Error(`Cannot replace ${source.name} across tags`)
}
return replacementNode
}
function nodeMakeIdentity(node: AnyNode): NodeIdentity {
if (node.service !== undefined) return { service: node.service }
return { name: node.name }
}
function isNode(input: Layer.Any | AnyNode): input is AnyNode {
return "kind" in input && "dependencies" in input
} }
// Tree ----------------------------------------------------------------------- // Tree -----------------------------------------------------------------------
@ -176,32 +203,38 @@ function walk<Result>(
return recur(root) return recur(root)
} }
export function hoist<A, E, T extends Tag>( export function hoist<A, E, T extends Tag, const Items extends Replacements = readonly []>(
root: Node<A, E, any>, root: Node<A, E, any>,
tag: T, tag: T,
replacements?: ValidReplacements<Items>,
): { ): {
readonly node: Node<A, E> readonly node: Node<A, E>
readonly hoisted: Node<unknown, E> readonly hoisted: Node<unknown, E>
} { } {
const hoisted = new Map<string, AnyNode>() const hoisted = new Map<string, AnyNode>()
const replacementMap = replacementMapFrom(replacements)
const node = walk<AnyNode>(root, (node, context) => { const node = walk<AnyNode>(
if (node.kind === "group") { root,
return { ...node, dependencies: node.dependencies.map(context.visit) } (node, context) => {
} if (node.kind === "group") {
if (node.tag === tag) { return { ...node, dependencies: node.dependencies.map(context.visit) }
const existing = hoisted.get(node.name)
if (existing && existing !== node) {
throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
} }
hoisted.set(node.name, node) if (node.tag === tag) {
return group([]) const existing = hoisted.get(node.name)
} if (existing && existing !== node) {
if (node.kind === "unbound") { throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
return node }
} hoisted.set(node.name, node)
return { ...node, dependencies: node.dependencies.map(context.visit) } return group([])
}) }
if (node.kind === "unbound") {
return node
}
return { ...node, dependencies: node.dependencies.map(context.visit) }
},
{ resolve: (node) => replacementMap.get(node.name) ?? node },
)
return { return {
node: node as Node<A, E>, node: node as Node<A, E>,
@ -209,10 +242,11 @@ export function hoist<A, E, T extends Tag>(
} }
} }
export function compile<A, E>( export function compile<A, E, const Items extends Replacements = readonly []>(
root: Node<A, E, any>, root: Node<A, E, any>,
replacements?: ReadonlyMap<Layer.Any, Layer.Any>, replacements?: ValidReplacements<Items>,
): Layer.Layer<A, E> { ): Layer.Layer<A, E> {
const replacementMap = replacementMapFrom(replacements)
const cache = new Map<AnyNode, RuntimeLayer>() const cache = new Map<AnyNode, RuntimeLayer>()
const compileNode = (node: AnyNode) => const compileNode = (node: AnyNode) =>
walk<RuntimeLayer>( walk<RuntimeLayer>(
@ -220,18 +254,22 @@ export function compile<A, E>(
(node, context) => { (node, context) => {
if (node.kind === "unbound") throw new Error(`Unbound layer node: ${node.name}`) if (node.kind === "unbound") throw new Error(`Unbound layer node: ${node.name}`)
const dependencies = node.dependencies.flatMap(flatten).map(context.visit) const dependencies = node.dependencies.flatMap(flatten).map(context.visit)
const implementation = (replacements?.get(node.implementation!) ?? node.implementation!) as RuntimeLayer const implementation = node.implementation! as RuntimeLayer
return dependencies.length === 0 return dependencies.length === 0
? implementation ? implementation
: implementation.pipe(Layer.provide(dependencies as [RuntimeLayer, ...RuntimeLayer[]])) : implementation.pipe(Layer.provide(dependencies as [RuntimeLayer, ...RuntimeLayer[]]))
}, },
{ cache }, { cache, resolve: (node) => replacementMap.get(node.name) ?? node },
) )
const layers = flatten(root).map((node) => compileNode(node)) const layers = flatten(root).map((node) => compileNode(node))
const layer = layers.reduce<RuntimeLayer>((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty) const layer = layers.reduce<RuntimeLayer>((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty)
return layer as Layer.Layer<A, E> return layer as Layer.Layer<A, E>
} }
function replacementMapFrom(replacements?: Replacements) {
return new Map(replacements?.map(([source, replacement]) => [source.name, replacementNode(source, replacement)]))
}
export function hasUnbound(root: Node<unknown, unknown, any>, source: AnyNode): boolean { export function hasUnbound(root: Node<unknown, unknown, any>, source: AnyNode): boolean {
if (source.kind !== "unbound") throw new Error(`Cannot check non-unbound layer node: ${source.name}`) if (source.kind !== "unbound") throw new Error(`Cannot check non-unbound layer node: ${source.name}`)
return walk<boolean>(root, (node, context) => { return walk<boolean>(root, (node, context) => {
@ -240,32 +278,6 @@ export function hasUnbound(root: Node<unknown, unknown, any>, source: AnyNode):
}) })
} }
export function bind<A, E, T extends Tag | undefined>(
root: Node<A, E, T>,
source: AnyNode,
replacement: AnyNode,
): Node<A, E, T> {
if (source.kind !== "unbound") throw new Error(`Cannot bind non-unbound layer node: ${source.name}`)
if (source.name !== replacement.name) {
throw new Error(`Cannot bind ${source.name} to ${replacement.name}`)
}
if (source.tag !== replacement.tag) {
throw new Error(`Cannot bind ${source.name} across tags`)
}
return walk<AnyNode>(
root,
(target, context) => {
if (target.kind === "unbound") return target
const dependencies: AnyNode[] = []
const clone = { ...target, dependencies }
context.cache.set(target, clone)
dependencies.push(...target.dependencies.map(context.visit))
return clone
},
{ detectCycles: false, resolve: (node) => (node === source ? replacement : node) },
) as Node<A, E, T>
}
function flatten(node: AnyNode): readonly AnyNode[] { function flatten(node: AnyNode): readonly AnyNode[] {
return node.kind === "group" ? node.dependencies.flatMap(flatten) : [node] return node.kind === "group" ? node.dependencies.flatMap(flatten) : [node]
} }

View file

@ -82,17 +82,16 @@ export type LocationServices = LayerNode.Output<typeof locationServices>
export type LocationError = LayerNode.Error<typeof locationServices> export type LocationError = LayerNode.Error<typeof locationServices>
export function buildLocationServiceMap( export function buildLocationServiceMap(
replacements?: ReadonlyMap<Layer.Any, Layer.Any>, replacements: LayerNode.Replacements = [],
): Layer.Layer<LocationServiceMap.Service> { ): Layer.Layer<LocationServiceMap.Service> {
return Layer.effect( return Layer.effect(
LocationServiceMap.Service, LocationServiceMap.Service,
LayerMap.make( LayerMap.make(
(ref: Location.Ref) => { (ref: Location.Ref) => {
const location = LayerNode.hoist( const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
LayerNode.bind(locationServices, Location.node, Location.boundNode(ref)), const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements)
Node.tags.values.global,
) return LayerNode.compile(location.node).pipe(
return LayerNode.compile(location.node, replacements).pipe(
Layer.fresh, Layer.fresh,
Layer.tap(() => Layer.tap(() =>
Effect.logInfo("booting location services", { Effect.logInfo("booting location services", {
@ -100,7 +99,7 @@ export function buildLocationServiceMap(
workspaceID: ref.workspaceID, workspaceID: ref.workspaceID,
}), }),
), ),
Layer.provide(LayerNode.compile(location.hoisted, replacements)), Layer.provide(LayerNode.compile(location.hoisted)),
) )
}, },
{ idleTimeToLive: "60 minutes" }, { idleTimeToLive: "60 minutes" },

View file

@ -196,6 +196,8 @@ export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node, Config.node] }) export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node, Config.node] })
export const nodeWithoutConfig = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node] })
/** Runs retention scanning once globally rather than once per active Location. */ /** Runs retention scanning once globally rather than once per active Location. */
export const cleanupLayer = Layer.effectDiscard( export const cleanupLayer = Layer.effectDiscard(
Effect.gen(function* () { Effect.gen(function* () {

View file

@ -1,7 +1,8 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Tool } from "@opencode-ai/core/tool/tool" import { Tool } from "@opencode-ai/core/tool/tool"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { PermissionV2 } from "@opencode-ai/core/permission" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionMessage } from "@opencode-ai/core/session/message"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
@ -9,19 +10,14 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { executeTool, settleTool, toolDefinitions } from "./lib/tool" import { executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { Tools } from "@opencode-ai/core/tool/tools" import { Tools } from "@opencode-ai/core/tool/tools"
import { Deferred, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect" import { Deferred, Effect, Exit, Fiber, Schema, Scope } from "effect"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const permission = Layer.mock(PermissionV2.Service, { const it = testEffect(
assert: () => Effect.void, AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, ToolRegistry.node, ToolRegistry.toolsNode]), [
}) [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
const applications = ApplicationTools.layer ]),
const registry = ToolRegistry.layer.pipe(
Layer.provide(permission),
Layer.provide(applications),
Layer.provide(ToolOutputStore.defaultLayer),
) )
const it = testEffect(Layer.mergeAll(applications, registry))
const sessionID = SessionV2.ID.make("ses_application_tool") const sessionID = SessionV2.ID.make("ses_application_tool")
const agent = AgentV2.ID.make("build") const agent = AgentV2.ID.make("build")

View file

@ -3,6 +3,8 @@ import { Effect, Fiber, Layer, Stream } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration" import { Integration } from "@opencode-ai/core/integration"
import { Credential } from "@opencode-ai/core/credential" import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
@ -21,12 +23,12 @@ const locationLayer = Layer.succeed(
Location.Service, Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("test") })), Location.Service.of(location({ directory: AbsolutePath.make("test") })),
) )
const catalogLayer = AppNodeBuilder.build(
LayerNode.group([Catalog.node, EventV2.node, Credential.node, Integration.node, Policy.node]),
[[Location.node, locationLayer]],
)
const it = testEffect( const it = testEffect(
Catalog.locationLayer.pipe( catalogLayer,
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer),
Layer.provideMerge(Credential.defaultLayer),
),
) )
describe("CatalogV2", () => { describe("CatalogV2", () => {
@ -47,11 +49,11 @@ describe("CatalogV2", () => {
it.effect("derives availability from active credentials without changing provider state", () => { it.effect("derives availability from active credentials without changing provider state", () => {
const integrationID = Integration.ID.make("test") const integrationID = Integration.ID.make("test")
const layer = Catalog.locationLayer.pipe( const localCatalogLayer = Layer.fresh(
Layer.fresh, AppNodeBuilder.build(
Layer.provideMerge(EventV2.defaultLayer), LayerNode.group([Catalog.node, Credential.node]),
Layer.provideMerge(locationLayer), [[Location.node, locationLayer]],
Layer.provideMerge(Credential.defaultLayer.pipe(Layer.fresh)), ),
) )
return Effect.gen(function* () { return Effect.gen(function* () {
@ -73,17 +75,17 @@ describe("CatalogV2", () => {
}) })
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")]) expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
}).pipe(Effect.provide(layer)) }).pipe(Effect.provide(localCatalogLayer))
}) })
it.effect("derives availability from a provider's integration", () => { it.effect("derives availability from a provider's integration", () => {
const integrationID = Integration.ID.make("gateway") const integrationID = Integration.ID.make("gateway")
const providerID = ProviderV2.ID.make("remote") const providerID = ProviderV2.ID.make("remote")
const layer = Catalog.locationLayer.pipe( const localCatalogLayer = Layer.fresh(
Layer.fresh, AppNodeBuilder.build(
Layer.provideMerge(EventV2.defaultLayer), LayerNode.group([Catalog.node, Credential.node, Integration.node]),
Layer.provideMerge(locationLayer), [[Location.node, locationLayer]],
Layer.provideMerge(Credential.defaultLayer.pipe(Layer.fresh)), ),
) )
return Effect.gen(function* () { return Effect.gen(function* () {
@ -102,7 +104,7 @@ describe("CatalogV2", () => {
}) })
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([providerID]) expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([providerID])
}).pipe(Effect.provide(layer)) }).pipe(Effect.provide(localCatalogLayer))
}) })
it.effect("projects environment connections without a catalog plugin", () => it.effect("projects environment connections without a catalog plugin", () =>

View file

@ -1,10 +1,12 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { Effect, Layer, Schema } from "effect" import { Effect, Schema } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent" import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
@ -12,7 +14,7 @@ import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { agentHost, host } from "../plugin/host" import { agentHost, host } from "../plugin/host"
const it = testEffect(Layer.mergeAll(AgentV2.locationLayer, FSUtil.defaultLayer)) const it = testEffect(AppNodeBuilder.build(LayerNode.group([AgentV2.node, FSUtil.node])))
const decode = Schema.decodeUnknownSync(Config.Info) const decode = Schema.decodeUnknownSync(Config.Info)
describe("ConfigAgentPlugin.Plugin", () => { describe("ConfigAgentPlugin.Plugin", () => {

View file

@ -1,10 +1,12 @@
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer, Schema } from "effect" import { Effect, Schema } from "effect"
import { CommandV2 } from "@opencode-ai/core/command" import { CommandV2 } from "@opencode-ai/core/command"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command" import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
@ -13,7 +15,7 @@ import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { host } from "../plugin/host" import { host } from "../plugin/host"
const it = testEffect(Layer.mergeAll(CommandV2.locationLayer, FSUtil.defaultLayer)) const it = testEffect(AppNodeBuilder.build(LayerNode.group([CommandV2.node, FSUtil.node])))
const decode = Schema.decodeUnknownSync(Config.Info) const decode = Schema.decodeUnknownSync(Config.Info)
describe("ConfigCommandPlugin.Plugin", () => { describe("ConfigCommandPlugin.Plugin", () => {

View file

@ -5,6 +5,9 @@ import { Effect, Layer, Schema } from "effect"
import { FastCheck } from "effect/testing" import { FastCheck } from "effect/testing"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
import { ConfigProvider } from "@opencode-ai/core/config/provider" import { ConfigProvider } from "@opencode-ai/core/config/provider"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate" import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
@ -25,21 +28,24 @@ function testLayer(
projectDirectory = directory, projectDirectory = directory,
vcs?: Project.Vcs, vcs?: Project.Vcs,
) { ) {
return Config.locationLayer.pipe( const configNode = makeLocationNode({
Layer.provide(FSUtil.defaultLayer), service: Config.Service,
Layer.provide(Global.layerWith({ config: globalDirectory })), layer: Layer.fresh(Config.layer.pipe(Layer.provide(Global.layerWith({ config: globalDirectory })))),
Layer.provide( deps: [FSUtil.node, Location.node, Policy.node],
Layer.succeed( })
Location.Service, const locationLayer = Layer.succeed(
Location.Service.of( Location.Service,
location( Location.Service.of(
{ directory: AbsolutePath.make(directory) }, location(
{ projectDirectory: AbsolutePath.make(projectDirectory), vcs }, { directory: AbsolutePath.make(directory) },
), { projectDirectory: AbsolutePath.make(projectDirectory), vcs },
),
), ),
), ),
) )
return AppNodeBuilder.build(
LayerNode.group([configNode, Policy.node]),
[[Location.node, locationLayer]],
)
} }
const provider = { const provider = {

View file

@ -1,10 +1,11 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Credential } from "@opencode-ai/core/credential" import { Credential } from "@opencode-ai/core/credential"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Integration } from "@opencode-ai/core/integration" import { Integration } from "@opencode-ai/core/integration"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const it = testEffect(Credential.defaultLayer) const it = testEffect(LayerNode.compile(Credential.node))
describe("Credential", () => { describe("Credential", () => {
it.effect("stores, updates, lists, and removes credentials", () => it.effect("stores, updates, lists, and removes credentials", () =>

View file

@ -56,16 +56,22 @@ const checkError: Layer.Layer<B, LayerError, never> = closedWithError
void checkClosed void checkClosed
void checkError void checkError
LayerNode.replace(aLayer, Layer.succeed(A, A.of({}))) LayerNode.compile(a, [[a, Layer.succeed(A, A.of({}))]])
LayerNode.compile(a, [[a, make({ service: A, layer: Layer.succeed(A, A.of({})), deps: [] })]])
// @ts-expect-error Replacement must provide A // @ts-expect-error Replacement must provide A
LayerNode.replace(aLayer, Layer.succeed(B, B.of({}))) LayerNode.compile(a, [[a, Layer.succeed(B, B.of({}))]])
// @ts-expect-error Node replacement must provide A
const invalidNodeReplacement = () => LayerNode.compile(a, [[a, b]])
void invalidNodeReplacement
// @ts-expect-error Replacement cannot introduce a new error // @ts-expect-error Replacement cannot introduce a new error
LayerNode.replace(aLayer, Layer.effect(A, Effect.fail(new OtherError()))) LayerNode.compile(a, [[a, Layer.effect(A, Effect.fail(new OtherError()))]])
// @ts-expect-error Replacement must be closed // @ts-expect-error Node replacement cannot introduce a new error
LayerNode.replace(bLayer, bLayer) const invalidNodeErrorReplacement = () => LayerNode.compile(a, [[a, make({ service: A, layer: Layer.effect(A, Effect.fail(new OtherError())), deps: [] })]])
void invalidNodeErrorReplacement
class TagA extends Context.Service<TagA, {}>()("test/TagA") {} class TagA extends Context.Service<TagA, {}>()("test/TagA") {}
class TagB extends Context.Service<TagB, {}>()("test/TagB") {} class TagB extends Context.Service<TagB, {}>()("test/TagB") {}

View file

@ -13,7 +13,7 @@ class App extends Context.Service<App, { readonly run: Effect.Effect<string[]> }
const tags = LayerNode.tags({ app: [] }) const tags = LayerNode.tags({ app: [] })
const make = tags.make("app") const make = tags.make("app")
const build = <A, E>(root: LayerNode.Node<A, E, any>, replacements?: readonly LayerNode.Replacement[]) => const build = <A, E>(root: LayerNode.Node<A, E, any>, replacements?: readonly LayerNode.Replacement[]) =>
LayerNode.compile(root, new Map(replacements?.map((item) => [item.source, item.replacement]))) as Layer.Layer<A, E> LayerNode.compile(root, replacements) as Layer.Layer<A, E>
const valueLayer = Layer.succeed(Value, Value.of({ value: "production" })) const valueLayer = Layer.succeed(Value, Value.of({ value: "production" }))
const greetingLayer = Layer.effect( const greetingLayer = Layer.effect(
Greeting, Greeting,
@ -63,21 +63,20 @@ describe("layer node", () => {
expect(await Effect.runPromise(program)).toEqual(["first", "second"]) expect(await Effect.runPromise(program)).toEqual(["first", "second"])
}) })
test("requires unbound nodes to be bound before compilation", async () => { test("requires unbound nodes to be replaced before compilation", async () => {
const unbound = LayerNode.unbound(Value, tags.values.app) const unbound = LayerNode.unbound(Value, tags.values.app)
const greeting = make({ service: Greeting, layer: greetingLayer, deps: [unbound] }) const greeting = make({ service: Greeting, layer: greetingLayer, deps: [unbound] })
const tree = LayerNode.group([greeting]) const tree = LayerNode.group([greeting])
expect(() => LayerNode.compile(tree)).toThrow("Unbound layer node: test/LayerNodeValue") expect(() => LayerNode.compile(tree)).toThrow("Unbound layer node: test/LayerNodeValue")
const bound = LayerNode.bind(tree, unbound, value) const layer = LayerNode.compile(tree, [[unbound, value]]) as Layer.Layer<Greeting>
const layer = LayerNode.compile(bound) as Layer.Layer<Greeting>
const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(layer)) const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(layer))
expect(await Effect.runPromise(program)).toBe("hello production") expect(await Effect.runPromise(program)).toBe("hello production")
}) })
test("replaces a layer by identity", async () => { test("replaces a node with a closed layer", async () => {
const replacement = Layer.succeed(Value, Value.of({ value: "simulation" })) const replacement = Layer.succeed(Value, Value.of({ value: "simulation" }))
const program = Effect.map(Greeting, (item) => item.value).pipe( const program = Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(build(LayerNode.group([greeting]), [LayerNode.replace(valueLayer, replacement)])), Effect.provide(build(LayerNode.group([greeting]), [[value, replacement]])),
) )
expect(await Effect.runPromise(program)).toBe("hello simulation") expect(await Effect.runPromise(program)).toBe("hello simulation")
}) })
@ -94,7 +93,7 @@ describe("layer node", () => {
const left = make({ service: Left, layer: leftLayer, deps: [value] }) const left = make({ service: Left, layer: leftLayer, deps: [value] })
const right = make({ service: Right, layer: rightLayer, deps: [value] }) const right = make({ service: Right, layer: rightLayer, deps: [value] })
const replacement = Layer.succeed(Value, Value.of({ value: "replaced" })) const replacement = Layer.succeed(Value, Value.of({ value: "replaced" }))
const layer = build(LayerNode.group([left, right]), [LayerNode.replace(valueLayer, replacement)]) const layer = build(LayerNode.group([left, right]), [[value, replacement]])
const program = Effect.gen(function* () { const program = Effect.gen(function* () {
return [(yield* Left).value, (yield* Right).value] return [(yield* Left).value, (yield* Right).value]
}).pipe(Effect.provide(layer)) }).pipe(Effect.provide(layer))
@ -103,22 +102,47 @@ describe("layer node", () => {
test("does not acquire an unused replacement", async () => { test("does not acquire an unused replacement", async () => {
let acquisitions = 0 let acquisitions = 0
const other = Layer.succeed(Value, Value.of({ value: "other" })) const other = make({ service: Left, layer: Layer.succeed(Left, Left.of({ value: "other" })), deps: [] })
const replacement = Layer.effect( const replacement = Layer.effect(
Value, Left,
Effect.sync(() => { Effect.sync(() => {
acquisitions++ acquisitions++
return Value.of({ value: "replacement" }) return Left.of({ value: "replacement" })
}), }),
) )
await Effect.runPromise( await Effect.runPromise(
Effect.map(Greeting, (item) => item.value).pipe( Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(build(LayerNode.group([greeting]), [LayerNode.replace(other, replacement)])), Effect.provide(build(LayerNode.group([greeting]), [[other, replacement]])),
), ),
) )
expect(acquisitions).toBe(0) expect(acquisitions).toBe(0)
}) })
test("replaces a node without acquiring its dependencies", async () => {
let acquisitions = 0
const dependencyLayer = Layer.effect(
Value,
Effect.sync(() => {
acquisitions++
return Value.of({ value: "dependency" })
}),
)
const dependency = make({ service: Value, layer: dependencyLayer, deps: [] })
const original = make({ service: Greeting, layer: greetingLayer, deps: [dependency] })
const replacement = make({
service: Greeting,
layer: Layer.succeed(Greeting, Greeting.of({ value: "replacement" })),
deps: [],
})
const program = Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(build(LayerNode.group([original]), [[original, replacement]])),
)
expect(await Effect.runPromise(program)).toBe("replacement")
expect(acquisitions).toBe(0)
})
test("hoists and compiles tagged graphs", async () => { test("hoists and compiles tagged graphs", async () => {
const tags = LayerNode.tags({ location: ["global"], global: [] }) const tags = LayerNode.tags({ location: ["global"], global: [] })
const global = tags.make("global") const global = tags.make("global")

View file

@ -1,8 +1,8 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Context, Effect, Layer, LayerMap, Option } from "effect" import { Context, Effect, Layer, LayerMap, Option } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Node } from "@opencode-ai/core/effect/app-node" import { Node } from "@opencode-ai/core/effect/app-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationError, LocationServices } from "@opencode-ai/core/location-services" import type { LocationError, LocationServices } from "@opencode-ai/core/location-services"
@ -31,7 +31,7 @@ describe("node build", () => {
expect(await Effect.runPromise(program)).toBe("plain") expect(await Effect.runPromise(program)).toBe("plain")
}) })
test("detects cycles through a bound location service map", () => { test("detects cycles through a replaced location service map", async () => {
const a = Node.makeGlobalNode({ const a = Node.makeGlobalNode({
service: CycleA, service: CycleA,
layer: Layer.effect(CycleA, Effect.as(LocationServiceMap.Service, CycleA.of({}))), layer: Layer.effect(CycleA, Effect.as(LocationServiceMap.Service, CycleA.of({}))),
@ -45,26 +45,28 @@ describe("node build", () => {
), ),
deps: [a], deps: [a],
}) })
const mapEffect = Effect.gen(function* () { const mapLayer = Layer.effect(
const service = yield* CycleB LocationServiceMap.Service,
return yield* LayerMap.make( Effect.gen(function* () {
(ref: Location.Ref) => const service = yield* CycleB
Layer.succeed( return yield* LayerMap.make(
Location.Service, (ref: Location.Ref) =>
Location.Service.of({ Layer.succeed(
directory: ref.directory, Location.Service,
workspaceID: ref.workspaceID, Location.Service.of({
project: { id: Project.ID.global, directory: service.directory }, directory: ref.directory,
}), workspaceID: ref.workspaceID,
), project: { id: Project.ID.global, directory: service.directory },
{ idleTimeToLive: "1 minute" }, }),
) ),
}) as unknown as Effect.Effect<LayerMap.LayerMap<Location.Ref, LocationServices, LocationError>, never, CycleB> { idleTimeToLive: "1 minute" },
const mapLayer = Layer.effect(LocationServiceMap.Service, mapEffect) )
}) as unknown as Effect.Effect<LayerMap.LayerMap<Location.Ref, LocationServices, LocationError>, never, CycleB>,
)
const map = Node.makeGlobalNode({ service: LocationServiceMap.Service, layer: mapLayer, deps: [b] }) const map = Node.makeGlobalNode({ service: LocationServiceMap.Service, layer: mapLayer, deps: [b] })
const graph = LayerNode.bind(LayerNode.group([a]), LocationServiceMap.node, map) expect(() => AppNodeBuilder.build(LayerNode.group([a]), [[LocationServiceMap.node, map]])).toThrow(
"Cycle detected in layer tree",
expect(() => AppNodeBuilder.build(graph)).toThrow("Cycle detected in layer tree") )
}) })
test("shares top-level project with location services", async () => { test("shares top-level project with location services", async () => {
@ -81,10 +83,10 @@ describe("node build", () => {
}) })
}), }),
) )
const layer = AppNodeBuilder.build(LayerNode.group([Project.node, LocationServiceMap.node]), [
LayerNode.replace(Project.layer, projectLayer),
])
const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }) const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
const layer = AppNodeBuilder.build(LayerNode.group([Project.node, LocationServiceMap.node]), [
[Project.node, projectLayer],
])
const program = Effect.gen(function* () { const program = Effect.gen(function* () {
yield* Project.Service yield* Project.Service
const locations = yield* LocationServiceMap.Service const locations = yield* LocationServiceMap.Service

View file

@ -1,11 +1,12 @@
import { describe, test, expect } from "bun:test" import { describe, test, expect } from "bun:test"
import { Effect, Layer, FileSystem } from "effect" import { Effect, FileSystem } from "effect"
import { NodeFileSystem } from "@effect/platform-node" import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import path from "path" import path from "path"
const live = Layer.merge(FSUtil.defaultLayer, NodeFileSystem.layer) const live = LayerNode.compile(LayerNode.group([FSUtil.node, LayerNodePlatform.filesystem]))
const { effect: it } = testEffect(live) const { effect: it } = testEffect(live)
describe("FSUtil", () => { describe("FSUtil", () => {

View file

@ -2,7 +2,6 @@ import fs from "fs/promises"
import path from "path" import path from "path"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Exit, Layer } from "effect" import { Effect, Exit, Layer } from "effect"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FileSystem } from "@opencode-ai/core/filesystem" import { FileSystem } from "@opencode-ai/core/filesystem"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
@ -14,18 +13,13 @@ import { it } from "./lib/effect"
const provide = (directory: string) => const provide = (directory: string) =>
Effect.provide( Effect.provide(
LayerNode.compile( LayerNode.compile(
LayerNode.bind( FileSystem.node,
FileSystem.node, [
Location.node, [
makeLocationNode({ Location.node,
service: Location.Service, Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
layer: Layer.succeed( ],
Location.Service, ],
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
),
deps: [],
}),
),
), ),
) )

View file

@ -2,7 +2,6 @@ import fs from "fs/promises"
import path from "path" import path from "path"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Effect, Layer, Schema } from "effect" import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation" import { LocationMutation } from "@opencode-ai/core/location-mutation"
@ -14,18 +13,13 @@ import { it } from "./lib/effect"
function provide(directory: string) { function provide(directory: string) {
return Effect.provide( return Effect.provide(
LayerNode.compile( LayerNode.compile(
LayerNode.bind( LocationMutation.node,
LocationMutation.node, [
Location.node, [
makeLocationNode({ Location.node,
service: Location.Service, Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
layer: Layer.succeed( ],
Location.Service, ],
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
),
deps: [],
}),
),
), ),
) )
} }

View file

@ -1,11 +1,12 @@
import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test" import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test"
import { Effect, Layer, Ref } from "effect" import { Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { FSUtil } from "@opencode-ai/core/fs-util" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { ModelsDev } from "@opencode-ai/core/models-dev" import { ModelsDev } from "@opencode-ai/core/models-dev"
import { EventV2 } from "@opencode-ai/core/event"
import { it } from "./lib/effect" import { it } from "./lib/effect"
import { readFile, rm, writeFile, utimes, mkdir } from "fs/promises" import { readFile, rm, writeFile, utimes, mkdir } from "fs/promises"
import path from "path" import path from "path"
@ -90,10 +91,10 @@ const buildLayer = (state: Ref.Ref<MockState>) =>
// Layer.fresh is required: ModelsDev.layer is a module-level Layer constant, // Layer.fresh is required: ModelsDev.layer is a module-level Layer constant,
// and Effect.provide uses a process-global MemoMap by default — without fresh, // and Effect.provide uses a process-global MemoMap by default — without fresh,
// every test would reuse the cachedInvalidateWithTTL state from the first run. // every test would reuse the cachedInvalidateWithTTL state from the first run.
Layer.fresh(ModelsDev.layer).pipe( Layer.fresh(
Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))), AppNodeBuilder.build(ModelsDev.node, [
Layer.provide(FSUtil.defaultLayer), [LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
Layer.provide(EventV2.defaultLayer), ]),
) )
const writeCacheText = (text: string, mtimeMs?: number) => const writeCacheText = (text: string, mtimeMs?: number) =>

View file

@ -3,30 +3,26 @@ import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration" import { Integration } from "@opencode-ai/core/integration"
import { Credential } from "@opencode-ai/core/credential" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { ModelsDev } from "@opencode-ai/core/models-dev" import { ModelsDev } from "@opencode-ai/core/models-dev"
import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev" import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev"
import { Policy } from "@opencode-ai/core/policy"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location" import { location } from "../fixture/location"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { catalogHost, host, integrationHost } from "./host" import { catalogHost, host, integrationHost } from "./host"
const events = EventV2.defaultLayer
const locationLayer = Layer.succeed( const locationLayer = Layer.succeed(
Location.Service, Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })), Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),
) )
const policy = Policy.layer.pipe(Layer.provide(locationLayer)) const layer = AppNodeBuilder.build(
const connections = Credential.defaultLayer.pipe(Layer.fresh) LayerNode.group([Catalog.node, Integration.node, EventV2.node]),
const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections)) [[Location.node, locationLayer]],
const catalog = Catalog.layer.pipe(
Layer.provide(Layer.mergeAll(events, locationLayer, policy, connections, integrations)),
) )
const layer = Layer.mergeAll(catalog.pipe(Layer.provide(connections)), integrations, connections, events, locationLayer)
const it = testEffect(layer) const it = testEffect(layer)
describe("ModelsDevPlugin", () => { describe("ModelsDevPlugin", () => {
@ -65,7 +61,7 @@ describe("ModelsDevPlugin", () => {
connections: [], connections: [],
}), }),
]) ])
}).pipe(Effect.provide(ModelsDev.defaultLayer)), }).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))),
(previous) => (previous) =>
Effect.sync(() => { Effect.sync(() => {
Flag.OPENCODE_MODELS_PATH = previous.path Flag.OPENCODE_MODELS_PATH = previous.path

View file

@ -1,11 +1,9 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { EventV2 } from "@opencode-ai/core/event" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { Policy } from "@opencode-ai/core/policy"
import { VariantPlugin } from "@opencode-ai/core/plugin/variant" import { VariantPlugin } from "@opencode-ai/core/plugin/variant"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
@ -14,20 +12,15 @@ import { location } from "../fixture/location"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { catalogHost, host } from "./host" import { catalogHost, host } from "./host"
const events = EventV2.defaultLayer
const locationLayer = Layer.succeed( const locationLayer = Layer.succeed(
Location.Service, Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })), Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),
) )
const connections = Credential.defaultLayer.pipe(Layer.fresh)
const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections))
const catalog = Catalog.layer.pipe(
Layer.provide(
Layer.mergeAll(events, locationLayer, Policy.layer.pipe(Layer.provide(locationLayer)), connections, integrations),
),
)
const it = testEffect( const it = testEffect(
Layer.mergeAll(catalog.pipe(Layer.provide(connections)), integrations, connections, events, locationLayer), AppNodeBuilder.build(
Catalog.node,
[[Location.node, locationLayer]],
),
) )
describe("VariantPlugin", () => { describe("VariantPlugin", () => {

View file

@ -3,9 +3,10 @@ import { $ } from "bun"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { Effect, Fiber, Layer, Stream } from "effect" import { Effect, Fiber, Stream } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Git } from "@opencode-ai/core/git" import { Git } from "@opencode-ai/core/git"
import { Database } from "@opencode-ai/core/database/database" import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
@ -16,15 +17,8 @@ import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const copyLayer = ProjectCopy.layer.pipe(
Layer.provide(Database.defaultLayer),
Layer.provide(ProjectDirectories.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
)
const it = testEffect( const it = testEffect(
Layer.mergeAll(copyLayer, Database.defaultLayer, EventV2.defaultLayer, ProjectDirectories.defaultLayer), AppNodeBuilder.build(LayerNode.group([ProjectCopy.node, Database.node, EventV2.node, ProjectDirectories.node])),
) )
function abs(input: string) { function abs(input: string) {

View file

@ -1,16 +1,18 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Exit, Layer, Scope } from "effect" import { Effect, Exit, Layer, Scope } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Reference } from "@opencode-ai/core/reference" import { Reference } from "@opencode-ai/core/reference"
import { Repository } from "@opencode-ai/core/repository" import { Repository } from "@opencode-ai/core/repository"
import { RepositoryCache } from "@opencode-ai/core/repository-cache" import { RepositoryCache } from "@opencode-ai/core/repository-cache"
import { EventV2 } from "@opencode-ai/core/event"
import { it } from "./lib/effect" import { it } from "./lib/effect"
const cache = Layer.mock(RepositoryCache.Service, { const cache = Layer.mock(RepositoryCache.Service, {
ensure: () => Effect.die("unexpected Git materialization"), ensure: () => Effect.die("unexpected Git materialization"),
}) })
const referenceLayer = AppNodeBuilder.build(Reference.node, [[RepositoryCache.node, cache]])
describe("Reference", () => { describe("Reference", () => {
it.effect("registers normalized sources for the owning scope", () => it.effect("registers normalized sources for the owning scope", () =>
@ -32,12 +34,7 @@ describe("Reference", () => {
yield* Scope.close(scope, Exit.void) yield* Scope.close(scope, Exit.void)
expect(yield* references.list()).toEqual([]) expect(yield* references.list()).toEqual([])
}).pipe( }).pipe(Effect.provide(referenceLayer)),
Effect.provide(Reference.layer),
Effect.provide(cache),
Effect.provide(EventV2.defaultLayer),
Effect.provide(Global.defaultLayer),
),
) )
it.effect("derives Git paths without exposing cache operations", () => it.effect("derives Git paths without exposing cache operations", () =>
@ -54,13 +51,7 @@ describe("Reference", () => {
source, source,
}), }),
]) ])
}).pipe( }).pipe(Effect.scoped, Effect.provide(referenceLayer)),
Effect.scoped,
Effect.provide(Reference.layer),
Effect.provide(cache),
Effect.provide(EventV2.defaultLayer),
Effect.provide(Global.defaultLayer),
),
) )
it.effect("preserves configured Git descriptions", () => it.effect("preserves configured Git descriptions", () =>
@ -82,12 +73,6 @@ describe("Reference", () => {
source, source,
}), }),
]) ])
}).pipe( }).pipe(Effect.scoped, Effect.provide(referenceLayer)),
Effect.scoped,
Effect.provide(Reference.layer),
Effect.provide(cache),
Effect.provide(EventV2.defaultLayer),
Effect.provide(Global.defaultLayer),
),
) )
}) })

View file

@ -3,12 +3,11 @@ import fs from "fs/promises"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Git } from "@opencode-ai/core/git" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Repository } from "@opencode-ai/core/repository" import { Repository } from "@opencode-ai/core/repository"
import { RepositoryCache } from "@opencode-ai/core/repository-cache" import { RepositoryCache } from "@opencode-ai/core/repository-cache"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { git, gitRemote } from "./fixture/git" import { git, gitRemote } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
@ -89,15 +88,9 @@ describe("RepositoryCache", () => {
}) })
function cacheLayer(root: string) { function cacheLayer(root: string) {
const dependencies = Layer.mergeAll( return AppNodeBuilder.build(RepositoryCache.node, [
Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") }), [Global.node, Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })],
FSUtil.defaultLayer, ])
)
return RepositoryCache.layer.pipe(
Layer.provide(EffectFlock.layer.pipe(Layer.provide(dependencies))),
Layer.provide(Git.defaultLayer),
Layer.provide(dependencies),
)
} }
function withRemote<A, E, R>(body: (fixture: Awaited<ReturnType<typeof gitRemote>>) => Effect.Effect<A, E, R>) { function withRemote<A, E, R>(body: (fixture: Awaited<ReturnType<typeof gitRemote>>) => Effect.Effect<A, E, R>) {

View file

@ -4,7 +4,6 @@ import { asc, eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database" import { Database } from "@opencode-ai/core/database/database"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql" import { EventTable } from "@opencode-ai/core/event/sql"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
@ -26,11 +25,8 @@ import { Snapshot } from "@opencode-ai/core/snapshot"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node]))) const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node])))
const sessionsLayer = AppNodeBuilder.build( const sessionsLayer = AppNodeBuilder.build(
LayerNode.bind( SessionV2.node,
SessionV2.node, [[SessionExecution.node, SessionExecution.noopLayer]],
SessionExecution.node,
makeGlobalNode({ service: SessionExecution.Service, layer: SessionExecution.noopLayer, deps: [] }),
),
) )
const sessionID = SessionV2.ID.make("ses_projector_test") const sessionID = SessionV2.ID.make("ses_projector_test")
const created = DateTime.makeUnsafe(0) const created = DateTime.makeUnsafe(0)

View file

@ -1,7 +1,8 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { asc, eq } from "drizzle-orm" import { asc, eq } from "drizzle-orm"
import { DateTime, Effect, Layer, Schema } from "effect" import { DateTime, Effect, Schema } from "effect"
import { Database } from "@opencode-ai/core/database/database" import { Database } from "@opencode-ai/core/database/database"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql" import { EventTable } from "@opencode-ai/core/event/sql"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
@ -16,7 +17,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionTable, SessionMessageTable } from "@opencode-ai/core/session/sql" import { SessionTable, SessionMessageTable } from "@opencode-ai/core/session/sql"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionProjector.defaultLayer)) const it = testEffect(LayerNode.compile(LayerNode.group([Database.node, EventV2.node, SessionProjector.node])))
const timestamp = DateTime.makeUnsafe(1) const timestamp = DateTime.makeUnsafe(1)
const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") } const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }

View file

@ -3,7 +3,9 @@ import path from "path"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { FSUtil } from "@opencode-ai/core/fs-util" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
@ -27,15 +29,14 @@ async function pull(skills: unknown[], files: Record<string, string> = {}, cache
), ),
), ),
) )
const layer = SkillDiscovery.layer.pipe( const skillDiscoveryLayer = AppNodeBuilder.build(SkillDiscovery.node, [
Layer.provide(http), [LayerNodePlatform.httpClient, http],
Layer.provide(FSUtil.defaultLayer), [Global.node, Global.layerWith({ cache: tmp.path })],
Layer.provide(Global.layerWith({ cache: tmp.path })), ])
)
const directories = await Effect.runPromise( const directories = await Effect.runPromise(
Effect.gen(function* () { Effect.gen(function* () {
return yield* (yield* SkillDiscovery.Service).pull(base) return yield* (yield* SkillDiscovery.Service).pull(base)
}).pipe(Effect.provide(layer)), }).pipe(Effect.provide(skillDiscoveryLayer)),
) )
return { tmp, requests, directories } return { tmp, requests, directories }
} }

View file

@ -24,9 +24,7 @@ const discovery = Layer.succeed(
}), }),
) )
const it = testEffect( const it = testEffect(
AppNodeBuilder.build(LayerNode.group([SkillV2.node, AgentV2.node]), [ AppNodeBuilder.build(LayerNode.group([SkillV2.node, AgentV2.node]), [[SkillDiscovery.node, discovery]]),
LayerNode.replace(SkillDiscovery.layer, discovery),
]),
) )
function write(directory: string, name: string, description: string) { function write(directory: string, name: string, description: string) {

View file

@ -1,7 +1,6 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import * as TestClock from "effect/testing/TestClock" import * as TestClock from "effect/testing/TestClock"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
@ -28,14 +27,12 @@ const locationLayer = Layer.succeed(
), ),
), ),
) )
const locationNode = makeLocationNode({ service: Location.Service, layer: locationLayer, deps: [] }) const builtInsNode = LayerNode.group([SystemContextBuiltIns.node, SystemContextRegistry.node])
const builtInsNode = LayerNode.bind(
LayerNode.group([SystemContextBuiltIns.node, SystemContextRegistry.node]),
Location.node,
locationNode,
)
const it = testEffect( const it = testEffect(
AppNodeBuilder.build(builtInsNode, [LayerNode.replace(Global.layer, Global.layerWith({ config: "/global" }))]), AppNodeBuilder.build(builtInsNode, [
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
]),
) )
const instructionFS = Layer.effect( const instructionFS = Layer.effect(
FSUtil.Service, FSUtil.Service,
@ -51,8 +48,9 @@ const instructionFS = Layer.effect(
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const itWithInstructions = testEffect( const itWithInstructions = testEffect(
AppNodeBuilder.build(builtInsNode, [ AppNodeBuilder.build(builtInsNode, [
LayerNode.replace(FSUtil.layer, instructionFS), [Location.node, locationLayer],
LayerNode.replace(Global.layer, Global.layerWith({ config: "/global" })), [FSUtil.node, instructionFS],
[Global.node, Global.layerWith({ config: "/global" })],
]), ]),
) )

View file

@ -1,6 +1,8 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import path from "path" import path from "path"
import { Cause, Effect, Exit, Fiber, Layer, Option } from "effect" import { Cause, Effect, Exit, Fiber, Layer, Option } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
@ -28,14 +30,14 @@ const withStore = <A, E, R>(
}), }),
) )
: Layer.empty : Layer.empty
const store = ToolOutputStore.layer.pipe(
Layer.provide(FSUtil.defaultLayer), const store = AppNodeBuilder.build(LayerNode.group([ToolOutputStore.node, FSUtil.node]), [
Layer.provide(global), [Global.node, global],
Layer.provide(configured), [Config.node, configured],
) ])
return Effect.gen(function* () { return Effect.gen(function* () {
return yield* body({ root: tmp.path, store: yield* ToolOutputStore.Service, fs: yield* FSUtil.Service }) return yield* body({ root: tmp.path, store: yield* ToolOutputStore.Service, fs: yield* FSUtil.Service })
}).pipe(Effect.provide(Layer.mergeAll(store, FSUtil.defaultLayer))) }).pipe(Effect.provide(store))
}, },
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
) )
@ -185,11 +187,11 @@ describe("ToolOutputStore", () => {
writeFileString: () => Effect.never, writeFileString: () => Effect.never,
}) })
}), }),
).pipe(Layer.provide(FSUtil.defaultLayer)) ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const store = ToolOutputStore.layer.pipe( const store = AppNodeBuilder.build(ToolOutputStore.nodeWithoutConfig, [
Layer.provide(blockedFilesystem), [Global.node, Global.layerWith({ data: root.path })],
Layer.provide(Global.layerWith({ data: root.path })), [FSUtil.node, blockedFilesystem],
) ])
const exit = yield* Effect.gen(function* () { const exit = yield* Effect.gen(function* () {
const service = yield* ToolOutputStore.Service const service = yield* ToolOutputStore.Service
const fiber = yield* service const fiber = yield* service

View file

@ -1,10 +1,13 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Exit, Fiber, Layer } from "effect" import { Effect, Exit, Fiber, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionV2 } from "@opencode-ai/core/permission"
import { QuestionV2 } from "@opencode-ai/core/question" import { QuestionV2 } from "@opencode-ai/core/question"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { QuestionTool } from "@opencode-ai/core/tool/question" import { QuestionTool } from "@opencode-ai/core/tool/question"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
@ -28,7 +31,10 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"), list: () => Effect.die("unused"),
}), }),
) )
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const registry = AppNodeBuilder.build(
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode]),
[[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig]],
)
const question = Layer.succeed( const question = Layer.succeed(
QuestionV2.Service, QuestionV2.Service,
QuestionV2.Service.of({ QuestionV2.Service.of({

View file

@ -1,12 +1,13 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node"
import path from "path" import path from "path"
import { Effect, FileSystem, Layer } from "effect" import { Effect, FileSystem } from "effect"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem" import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const it = testEffect(Layer.merge(FSUtil.defaultLayer, NodeFileSystem.layer)) const it = testEffect(LayerNode.compile(LayerNode.group([FSUtil.node, LayerNodePlatform.filesystem])))
const fixture = Effect.gen(function* () { const fixture = Effect.gen(function* () {
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const files = yield* FileSystem.FileSystem const files = yield* FileSystem.FileSystem

View file

@ -2,6 +2,8 @@ import fs from "fs/promises"
import path from "path" import path from "path"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
@ -9,6 +11,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { SkillV2 } from "@opencode-ai/core/skill" import { SkillV2 } from "@opencode-ai/core/skill"
import { SkillTool } from "@opencode-ai/core/tool/skill" import { SkillTool } from "@opencode-ai/core/tool/skill"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect" import { it } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
@ -63,11 +66,14 @@ describe("SkillTool", () => {
list: () => Effect.succeed(current), list: () => Effect.succeed(current),
}), }),
) )
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const registry = AppNodeBuilder.build(
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode]),
[[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig]],
)
const tool = SkillTool.layer.pipe( const tool = SkillTool.layer.pipe(
Layer.provide(registry), Layer.provide(registry),
Layer.provide(permission), Layer.provide(permission),
Layer.provide(FSUtil.defaultLayer), Layer.provide(LayerNode.compile(FSUtil.node)),
Layer.provide(skills), Layer.provide(skills),
) )
const layer = Layer.mergeAll(permission, skills, registry, tool) const layer = Layer.mergeAll(permission, skills, registry, tool)

View file

@ -3,9 +3,10 @@ import { spawn } from "child_process"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import os from "os" import os from "os"
import { Cause, Effect, Exit, Layer } from "effect" import { Cause, Effect, Exit } from "effect"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { FSUtil } from "@opencode-ai/core/fs-util" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Hash } from "@opencode-ai/core/util/hash" import { Hash } from "@opencode-ai/core/util/hash"
@ -109,7 +110,7 @@ const testGlobal = Global.layerWith({
log: os.tmpdir(), log: os.tmpdir(),
}) })
const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(FSUtil.defaultLayer)) const testLayer = AppNodeBuilder.build(EffectFlock.node, [[Global.node, testGlobal]])
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tests // Tests

View file

@ -176,12 +176,12 @@ const root = LayerNode.group([
CrossSpawnSpawner.node, CrossSpawnSpawner.node,
]) ])
const replacements = [ const replacements = [
LayerNode.replace(SessionSummary.layer, summary), [SessionSummary.node, summary],
LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer({ experimentalEventSystem: true })), [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })],
] ] as const
const env = LayerNode.compile( const env = LayerNode.compile(
LayerNode.group([root, LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] })]), LayerNode.group([root, LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] })]),
new Map(replacements.map((item) => [item.source, item.replacement])), replacements,
) )
const it = testEffect(env) const it = testEffect(env)
@ -208,9 +208,7 @@ const providerErrorLLM = Layer.succeed(
) )
const providerErrorEnv = LayerNode.compile( const providerErrorEnv = LayerNode.compile(
root, root,
new Map( [...replacements, [LLM.node, providerErrorLLM]],
[...replacements, LayerNode.replace(LLM.layer, providerErrorLLM)].map((item) => [item.source, item.replacement]),
),
) )
const itProviderError = testEffect(providerErrorEnv) const itProviderError = testEffect(providerErrorEnv)
@ -230,9 +228,7 @@ const fragmentFailureLLM = Layer.succeed(
) )
const fragmentFailureEnv = LayerNode.compile( const fragmentFailureEnv = LayerNode.compile(
root, root,
new Map( [...replacements, [LLM.node, fragmentFailureLLM]],
[...replacements, LayerNode.replace(LLM.layer, fragmentFailureLLM)].map((item) => [item.source, item.replacement]),
),
) )
const itFragmentFailure = testEffect(fragmentFailureEnv) const itFragmentFailure = testEffect(fragmentFailureEnv)

View file

@ -89,13 +89,11 @@ const root = LayerNode.group([
const it = testEffect( const it = testEffect(
LayerNode.compile( LayerNode.compile(
root, root,
new Map( [
[ [MCP.node, mcp],
LayerNode.replace(MCP.layer, mcp), [LSP.node, lsp],
LayerNode.replace(LSP.layer, lsp), [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })],
LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer({ experimentalEventSystem: true })), ],
].map((item) => [item.source, item.replacement]),
),
), ),
) )

View file

@ -1,6 +1,6 @@
import { beforeEach, describe, expect } from "bun:test" import { beforeEach, describe, expect } from "bun:test"
import { Effect, Exit, Layer, Option } from "effect" import { Effect, Exit, Layer, Option } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { httpClient } from "@opencode-ai/core/effect/app-node-platform"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
@ -34,15 +34,15 @@ const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unkno
const none = HttpClient.make(() => Effect.die("unexpected http call")) const none = HttpClient.make(() => Effect.die("unexpected http call"))
function requestLayer(client: HttpClient.HttpClient) { function requestLayer(client: HttpClient.HttpClient) {
const replacement = LayerNode.replace(FetchHttpClient.layer, Layer.succeed(HttpClient.HttpClient, client)) const replacement = [httpClient, Layer.succeed(HttpClient.HttpClient, client)] as const
return LayerNode.compile( return LayerNode.compile(
LayerNode.group([ShareNext.node, AccountRepo.node]), LayerNode.group([ShareNext.node, AccountRepo.node]),
new Map([[replacement.source, replacement.replacement]]), [replacement],
) )
} }
function integrationLayer(client: HttpClient.HttpClient) { function integrationLayer(client: HttpClient.HttpClient) {
const replacement = LayerNode.replace(FetchHttpClient.layer, Layer.succeed(HttpClient.HttpClient, client)) const replacement = [httpClient, Layer.succeed(HttpClient.HttpClient, client)] as const
return LayerNode.compile( return LayerNode.compile(
LayerNode.group([ LayerNode.group([
ShareNext.node, ShareNext.node,
@ -52,7 +52,7 @@ function integrationLayer(client: HttpClient.HttpClient) {
AccountRepo.node, AccountRepo.node,
Database.node, Database.node,
]), ]),
new Map([[replacement.source, replacement.replacement]]), [replacement],
) )
} }

View file

@ -50,20 +50,15 @@ const brokenPluginLayer = Layer.succeed(
const root = LayerNode.group([ToolRegistry.node, Agent.node]) const root = LayerNode.group([ToolRegistry.node, Agent.node])
const replacements = [ const replacements = [
LayerNode.replace(Config.layer, configLayer), [Config.node, configLayer],
LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer()), [RuntimeFlags.node, RuntimeFlags.layer()],
] ] as const
const it = testEffect(LayerNode.compile(root, new Map(replacements.map((item) => [item.source, item.replacement])))) const it = testEffect(LayerNode.compile(root, replacements))
const withBrokenPlugin = testEffect( const withBrokenPlugin = testEffect(
LayerNode.compile( LayerNode.compile(
root, root,
new Map( [...replacements, [Plugin.node, brokenPluginLayer]],
[...replacements, LayerNode.replace(Plugin.layer, brokenPluginLayer)].map((item) => [
item.source,
item.replacement,
]),
),
), ),
) )

View file

@ -50,7 +50,8 @@ export function createEmbeddedRoutes() {
function makeRoutes<AuthError, AuthServices>(auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>) { function makeRoutes<AuthError, AuthServices>(auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>) {
const serviceLayer = AppNodeBuilder.build( const serviceLayer = AppNodeBuilder.build(
LayerNode.bind(applicationServices, SessionExecution.node, SessionExecutionLocal.node), applicationServices,
[[SessionExecution.node, SessionExecutionLocal.node]],
) )
return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(