From 84336e4f9184ce66d767fc4f987a98eb5b8a7b07 Mon Sep 17 00:00:00 2001 From: James Long Date: Sun, 28 Jun 2026 23:48:18 -0400 Subject: [PATCH] refactor(core): refine layer node replacements (#34377) --- packages/core/src/effect/app-node-builder.ts | 23 ++-- packages/core/src/effect/dfdf | 1 + packages/core/src/effect/layer-node.ts | 124 ++++++++++-------- packages/core/src/location-services.ts | 13 +- packages/core/src/tool-output-store.ts | 2 + packages/core/test/application-tools.test.ts | 18 +-- packages/core/test/catalog.test.ts | 36 ++--- packages/core/test/config/agent.test.ts | 6 +- packages/core/test/config/command.test.ts | 6 +- packages/core/test/config/config.test.ts | 30 +++-- packages/core/test/credential.test.ts | 3 +- .../layer-node/layer-node-types.test.ts | 16 ++- .../test/effect/layer-node/layer-node.test.ts | 46 +++++-- .../test/effect/layer-node/node-build.test.ts | 50 +++---- .../core/test/filesystem/filesystem.test.ts | 7 +- .../core/test/location-filesystem.test.ts | 20 +-- packages/core/test/location-mutation.test.ts | 20 +-- packages/core/test/models.test.ts | 13 +- packages/core/test/plugin/models-dev.test.ts | 16 +-- packages/core/test/plugin/variant.test.ts | 19 +-- packages/core/test/project-copy.test.ts | 14 +- packages/core/test/reference.test.ts | 27 +--- packages/core/test/repository-cache.test.ts | 17 +-- packages/core/test/session-projector.test.ts | 8 +- .../core/test/session-tool-progress.test.ts | 5 +- packages/core/test/skill-discovery.test.ts | 15 ++- packages/core/test/skill.test.ts | 4 +- .../core/test/system-context/builtins.test.ts | 18 ++- packages/core/test/tool-output-store.test.ts | 24 ++-- packages/core/test/tool-question.test.ts | 8 +- .../core/test/tool-read-filesystem.test.ts | 7 +- packages/core/test/tool-skill.test.ts | 10 +- packages/core/test/util/effect-flock.test.ts | 7 +- .../test/session/processor-effect.test.ts | 16 +-- .../test/session/snapshot-tool-race.test.ts | 12 +- .../opencode/test/share/share-next.test.ts | 10 +- packages/opencode/test/tool/registry.test.ts | 15 +-- packages/server/src/routes.ts | 3 +- 38 files changed, 347 insertions(+), 342 deletions(-) create mode 100644 packages/core/src/effect/dfdf diff --git a/packages/core/src/effect/app-node-builder.ts b/packages/core/src/effect/app-node-builder.ts index a9d51b7d2f..5f01469dab 100644 --- a/packages/core/src/effect/app-node-builder.ts +++ b/packages/core/src/effect/app-node-builder.ts @@ -1,24 +1,23 @@ -import { Layer } from "effect" import { buildLocationServiceMap } from "../location-services" import { LocationServiceMap } from "../location-service-map" import { LayerNode } from "./layer-node" import { makeGlobalNode } from "./app-node" -export function build(root: LayerNode.Node, replacements?: readonly LayerNode.Replacement[]) { - const replacementMap = new Map(replacements?.map((item) => [item.source, item.replacement])) +export function build(root: LayerNode.Node, replacements: LayerNode.Replacements = []) { + let allReplacements = replacements - if (!LayerNode.hasUnbound(root, LocationServiceMap.node)) { - // If the location service map is not needed, we shouldn't pull it - // in. Compile the graph normally - return LayerNode.compile(root, replacementMap) + // Only build the location service map if it's actually needed + if (LayerNode.hasUnbound(root, LocationServiceMap.node) && !hasReplacement(replacements, LocationServiceMap.node)) { + const locationMap = buildLocationServiceMap(replacements) + const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] }) + allReplacements = replacements.concat([[LocationServiceMap.node, locationMapNode]]) } - const locationMap = buildLocationServiceMap(replacementMap) - const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] }) + return LayerNode.compile(root, allReplacements) +} - const app = LayerNode.bind(root, LocationServiceMap.node, locationMapNode) - - return LayerNode.compile(app, replacementMap) +function hasReplacement(replacements: LayerNode.Replacements, node: LayerNode.Node) { + return replacements.some(([source]) => source.name === node.name) } export * as AppNodeBuilder from "./app-node-builder" diff --git a/packages/core/src/effect/dfdf b/packages/core/src/effect/dfdf new file mode 100644 index 0000000000..77f2d57968 --- /dev/null +++ b/packages/core/src/effect/dfdf @@ -0,0 +1 @@ +File to save in: ~/.local/share/opencode/worktree/012780/location-layer-tiers/packages/core/src/effect/ \ No newline at end of file diff --git a/packages/core/src/effect/layer-node.ts b/packages/core/src/effect/layer-node.ts index 065790f8b3..988941f8cb 100644 --- a/packages/core/src/effect/layer-node.ts +++ b/packages/core/src/effect/layer-node.ts @@ -111,20 +111,47 @@ export function group( return { kind: "group", name: "group", dependencies } } -export type Replacement = { - readonly source: Layer.Any - readonly replacement: Layer.Any -} +export type Replacement = readonly [source: AnyNode, replacement: AnyNode | Layer.Any] +export type Replacements = readonly Replacement[] type CheckReplacementErrors = [Exclude] extends [never] ? unknown : { readonly "New replacement errors": Exclude } -export function replace( - source: Layer.Layer, - replacement: Layer.Layer, E2, never> & CheckReplacementErrors>, -): Replacement { - return { source, replacement } +type CheckReplacement = Item extends readonly [Node, infer Replacement] + ? Replacement extends Node, infer E2, T> + ? CheckReplacementErrors> + : Replacement extends Layer.Layer, infer E2, never> + ? CheckReplacementErrors> + : { readonly "Invalid replacement": Replacement } + : { readonly "Invalid replacement": Item } + +type CheckReplacements = { + readonly [K in keyof Items]: CheckReplacement +} + +type ValidReplacements = Items & CheckReplacements + +function replacementNode(source: AnyNode, replacement: AnyNode | Layer.Any) { + const replacementNode = isNode(replacement) + ? replacement + : make({ ...nodeMakeIdentity(source), layer: replacement as Layer.Layer, 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 ----------------------------------------------------------------------- @@ -176,32 +203,38 @@ function walk( return recur(root) } -export function hoist( +export function hoist( root: Node, tag: T, + replacements?: ValidReplacements, ): { readonly node: Node readonly hoisted: Node } { const hoisted = new Map() + const replacementMap = replacementMapFrom(replacements) - const node = walk(root, (node, context) => { - if (node.kind === "group") { - return { ...node, dependencies: node.dependencies.map(context.visit) } - } - if (node.tag === tag) { - const existing = hoisted.get(node.name) - if (existing && existing !== node) { - throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`) + const node = walk( + root, + (node, context) => { + if (node.kind === "group") { + return { ...node, dependencies: node.dependencies.map(context.visit) } } - hoisted.set(node.name, node) - return group([]) - } - if (node.kind === "unbound") { - return node - } - return { ...node, dependencies: node.dependencies.map(context.visit) } - }) + if (node.tag === tag) { + 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) + return group([]) + } + if (node.kind === "unbound") { + return node + } + return { ...node, dependencies: node.dependencies.map(context.visit) } + }, + { resolve: (node) => replacementMap.get(node.name) ?? node }, + ) return { node: node as Node, @@ -209,10 +242,11 @@ export function hoist( } } -export function compile( +export function compile( root: Node, - replacements?: ReadonlyMap, + replacements?: ValidReplacements, ): Layer.Layer { + const replacementMap = replacementMapFrom(replacements) const cache = new Map() const compileNode = (node: AnyNode) => walk( @@ -220,18 +254,22 @@ export function compile( (node, context) => { if (node.kind === "unbound") throw new Error(`Unbound layer node: ${node.name}`) 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 ? implementation : 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 layer = layers.reduce((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty) return layer as Layer.Layer } +function replacementMapFrom(replacements?: Replacements) { + return new Map(replacements?.map(([source, replacement]) => [source.name, replacementNode(source, replacement)])) +} + export function hasUnbound(root: Node, source: AnyNode): boolean { if (source.kind !== "unbound") throw new Error(`Cannot check non-unbound layer node: ${source.name}`) return walk(root, (node, context) => { @@ -240,32 +278,6 @@ export function hasUnbound(root: Node, source: AnyNode): }) } -export function bind( - root: Node, - source: AnyNode, - replacement: AnyNode, -): Node { - 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( - 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 -} - function flatten(node: AnyNode): readonly AnyNode[] { return node.kind === "group" ? node.dependencies.flatMap(flatten) : [node] } diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 07669c0843..7a4678a32c 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -82,17 +82,16 @@ export type LocationServices = LayerNode.Output export type LocationError = LayerNode.Error export function buildLocationServiceMap( - replacements?: ReadonlyMap, + replacements: LayerNode.Replacements = [], ): Layer.Layer { return Layer.effect( LocationServiceMap.Service, LayerMap.make( (ref: Location.Ref) => { - const location = LayerNode.hoist( - LayerNode.bind(locationServices, Location.node, Location.boundNode(ref)), - Node.tags.values.global, - ) - return LayerNode.compile(location.node, replacements).pipe( + const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]]) + const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements) + + return LayerNode.compile(location.node).pipe( Layer.fresh, Layer.tap(() => Effect.logInfo("booting location services", { @@ -100,7 +99,7 @@ export function buildLocationServiceMap( workspaceID: ref.workspaceID, }), ), - Layer.provide(LayerNode.compile(location.hoisted, replacements)), + Layer.provide(LayerNode.compile(location.hoisted)), ) }, { idleTimeToLive: "60 minutes" }, diff --git a/packages/core/src/tool-output-store.ts b/packages/core/src/tool-output-store.ts index 8513259dd6..d9b50c1f83 100644 --- a/packages/core/src/tool-output-store.ts +++ b/packages/core/src/tool-output-store.ts @@ -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 nodeWithoutConfig = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node] }) + /** Runs retention scanning once globally rather than once per active Location. */ export const cleanupLayer = Layer.effectDiscard( Effect.gen(function* () { diff --git a/packages/core/test/application-tools.test.ts b/packages/core/test/application-tools.test.ts index dac711f3c6..93feeeae3c 100644 --- a/packages/core/test/application-tools.test.ts +++ b/packages/core/test/application-tools.test.ts @@ -1,7 +1,8 @@ import { describe, expect } from "bun:test" import { Tool } from "@opencode-ai/core/tool/tool" 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 { SessionMessage } from "@opencode-ai/core/session/message" 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 { ToolOutputStore } from "@opencode-ai/core/tool-output-store" 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" -const permission = Layer.mock(PermissionV2.Service, { - assert: () => Effect.void, -}) -const applications = ApplicationTools.layer -const registry = ToolRegistry.layer.pipe( - Layer.provide(permission), - Layer.provide(applications), - Layer.provide(ToolOutputStore.defaultLayer), +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, ToolRegistry.node, ToolRegistry.toolsNode]), [ + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ]), ) -const it = testEffect(Layer.mergeAll(applications, registry)) const sessionID = SessionV2.ID.make("ses_application_tool") const agent = AgentV2.ID.make("build") diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index a3a1c4972d..da378af2f8 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -3,6 +3,8 @@ import { Effect, Fiber, Layer, Stream } from "effect" import { Catalog } from "@opencode-ai/core/catalog" 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 { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" @@ -21,12 +23,12 @@ const locationLayer = Layer.succeed( Location.Service, 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( - Catalog.locationLayer.pipe( - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(locationLayer), - Layer.provideMerge(Credential.defaultLayer), - ), + catalogLayer, ) describe("CatalogV2", () => { @@ -47,11 +49,11 @@ describe("CatalogV2", () => { it.effect("derives availability from active credentials without changing provider state", () => { const integrationID = Integration.ID.make("test") - const layer = Catalog.locationLayer.pipe( - Layer.fresh, - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(locationLayer), - Layer.provideMerge(Credential.defaultLayer.pipe(Layer.fresh)), + const localCatalogLayer = Layer.fresh( + AppNodeBuilder.build( + LayerNode.group([Catalog.node, Credential.node]), + [[Location.node, locationLayer]], + ), ) 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(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", () => { const integrationID = Integration.ID.make("gateway") const providerID = ProviderV2.ID.make("remote") - const layer = Catalog.locationLayer.pipe( - Layer.fresh, - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(locationLayer), - Layer.provideMerge(Credential.defaultLayer.pipe(Layer.fresh)), + const localCatalogLayer = Layer.fresh( + AppNodeBuilder.build( + LayerNode.group([Catalog.node, Credential.node, Integration.node]), + [[Location.node, locationLayer]], + ), ) return Effect.gen(function* () { @@ -102,7 +104,7 @@ describe("CatalogV2", () => { }) 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", () => diff --git a/packages/core/test/config/agent.test.ts b/packages/core/test/config/agent.test.ts index ea553671bd..4a28fb7877 100644 --- a/packages/core/test/config/agent.test.ts +++ b/packages/core/test/config/agent.test.ts @@ -1,10 +1,12 @@ import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" -import { Effect, Layer, Schema } from "effect" +import { Effect, Schema } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Config } from "@opencode-ai/core/config" 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 { PermissionV2 } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -12,7 +14,7 @@ import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" 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) describe("ConfigAgentPlugin.Plugin", () => { diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts index 49df4eeec1..f5a08aab63 100644 --- a/packages/core/test/config/command.test.ts +++ b/packages/core/test/config/command.test.ts @@ -1,10 +1,12 @@ import fs from "fs/promises" import path from "path" 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 { Config } from "@opencode-ai/core/config" 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 { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -13,7 +15,7 @@ import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" 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) describe("ConfigCommandPlugin.Plugin", () => { diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 836975937a..24f4aa969f 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -5,6 +5,9 @@ import { Effect, Layer, Schema } from "effect" import { FastCheck } from "effect/testing" import { Config } from "@opencode-ai/core/config" 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 { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -25,21 +28,24 @@ function testLayer( projectDirectory = directory, vcs?: Project.Vcs, ) { - return Config.locationLayer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Global.layerWith({ config: globalDirectory })), - Layer.provide( - Layer.succeed( - Location.Service, - Location.Service.of( - location( - { directory: AbsolutePath.make(directory) }, - { projectDirectory: AbsolutePath.make(projectDirectory), vcs }, - ), - ), + const configNode = makeLocationNode({ + service: Config.Service, + layer: Layer.fresh(Config.layer.pipe(Layer.provide(Global.layerWith({ config: globalDirectory })))), + deps: [FSUtil.node, Location.node, Policy.node], + }) + const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make(directory) }, + { projectDirectory: AbsolutePath.make(projectDirectory), vcs }, ), ), ) + return AppNodeBuilder.build( + LayerNode.group([configNode, Policy.node]), + [[Location.node, locationLayer]], + ) } const provider = { diff --git a/packages/core/test/credential.test.ts b/packages/core/test/credential.test.ts index ce3efdb2c8..c6070145d1 100644 --- a/packages/core/test/credential.test.ts +++ b/packages/core/test/credential.test.ts @@ -1,10 +1,11 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { Credential } from "@opencode-ai/core/credential" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Integration } from "@opencode-ai/core/integration" import { testEffect } from "./lib/effect" -const it = testEffect(Credential.defaultLayer) +const it = testEffect(LayerNode.compile(Credential.node)) describe("Credential", () => { it.effect("stores, updates, lists, and removes credentials", () => diff --git a/packages/core/test/effect/layer-node/layer-node-types.test.ts b/packages/core/test/effect/layer-node/layer-node-types.test.ts index a12bb06e3f..fb3461a585 100644 --- a/packages/core/test/effect/layer-node/layer-node-types.test.ts +++ b/packages/core/test/effect/layer-node/layer-node-types.test.ts @@ -56,16 +56,22 @@ const checkError: Layer.Layer = closedWithError void checkClosed 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 -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 -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 -LayerNode.replace(bLayer, bLayer) +// @ts-expect-error Node replacement cannot introduce a new error +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()("test/TagA") {} class TagB extends Context.Service()("test/TagB") {} diff --git a/packages/core/test/effect/layer-node/layer-node.test.ts b/packages/core/test/effect/layer-node/layer-node.test.ts index 64be1d71bc..5221474868 100644 --- a/packages/core/test/effect/layer-node/layer-node.test.ts +++ b/packages/core/test/effect/layer-node/layer-node.test.ts @@ -13,7 +13,7 @@ class App extends Context.Service } const tags = LayerNode.tags({ app: [] }) const make = tags.make("app") const build = (root: LayerNode.Node, replacements?: readonly LayerNode.Replacement[]) => - LayerNode.compile(root, new Map(replacements?.map((item) => [item.source, item.replacement]))) as Layer.Layer + LayerNode.compile(root, replacements) as Layer.Layer const valueLayer = Layer.succeed(Value, Value.of({ value: "production" })) const greetingLayer = Layer.effect( Greeting, @@ -63,21 +63,20 @@ describe("layer node", () => { 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 greeting = make({ service: Greeting, layer: greetingLayer, deps: [unbound] }) const tree = LayerNode.group([greeting]) expect(() => LayerNode.compile(tree)).toThrow("Unbound layer node: test/LayerNodeValue") - const bound = LayerNode.bind(tree, unbound, value) - const layer = LayerNode.compile(bound) as Layer.Layer + const layer = LayerNode.compile(tree, [[unbound, value]]) as Layer.Layer const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(layer)) 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 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") }) @@ -94,7 +93,7 @@ describe("layer node", () => { const left = make({ service: Left, layer: leftLayer, deps: [value] }) const right = make({ service: Right, layer: rightLayer, deps: [value] }) 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* () { return [(yield* Left).value, (yield* Right).value] }).pipe(Effect.provide(layer)) @@ -103,22 +102,47 @@ describe("layer node", () => { test("does not acquire an unused replacement", async () => { 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( - Value, + Left, Effect.sync(() => { acquisitions++ - return Value.of({ value: "replacement" }) + return Left.of({ value: "replacement" }) }), ) await Effect.runPromise( 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) }) + 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 () => { const tags = LayerNode.tags({ location: ["global"], global: [] }) const global = tags.make("global") diff --git a/packages/core/test/effect/layer-node/node-build.test.ts b/packages/core/test/effect/layer-node/node-build.test.ts index be30a4c1c5..e9ff2fc149 100644 --- a/packages/core/test/effect/layer-node/node-build.test.ts +++ b/packages/core/test/effect/layer-node/node-build.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test" 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 { 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 { LocationServiceMap } from "@opencode-ai/core/location-service-map" import type { LocationError, LocationServices } from "@opencode-ai/core/location-services" @@ -31,7 +31,7 @@ describe("node build", () => { 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({ service: CycleA, layer: Layer.effect(CycleA, Effect.as(LocationServiceMap.Service, CycleA.of({}))), @@ -45,26 +45,28 @@ describe("node build", () => { ), deps: [a], }) - const mapEffect = Effect.gen(function* () { - const service = yield* CycleB - return yield* LayerMap.make( - (ref: Location.Ref) => - Layer.succeed( - Location.Service, - Location.Service.of({ - directory: ref.directory, - workspaceID: ref.workspaceID, - project: { id: Project.ID.global, directory: service.directory }, - }), - ), - { idleTimeToLive: "1 minute" }, - ) - }) as unknown as Effect.Effect, never, CycleB> - const mapLayer = Layer.effect(LocationServiceMap.Service, mapEffect) + const mapLayer = Layer.effect( + LocationServiceMap.Service, + Effect.gen(function* () { + const service = yield* CycleB + return yield* LayerMap.make( + (ref: Location.Ref) => + Layer.succeed( + Location.Service, + Location.Service.of({ + directory: ref.directory, + workspaceID: ref.workspaceID, + project: { id: Project.ID.global, directory: service.directory }, + }), + ), + { idleTimeToLive: "1 minute" }, + ) + }) as unknown as Effect.Effect, never, CycleB>, + ) const map = Node.makeGlobalNode({ service: LocationServiceMap.Service, layer: mapLayer, deps: [b] }) - const graph = LayerNode.bind(LayerNode.group([a]), LocationServiceMap.node, map) - - expect(() => AppNodeBuilder.build(graph)).toThrow("Cycle detected in layer tree") + expect(() => AppNodeBuilder.build(LayerNode.group([a]), [[LocationServiceMap.node, map]])).toThrow( + "Cycle detected in layer tree", + ) }) 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 layer = AppNodeBuilder.build(LayerNode.group([Project.node, LocationServiceMap.node]), [ + [Project.node, projectLayer], + ]) const program = Effect.gen(function* () { yield* Project.Service const locations = yield* LocationServiceMap.Service diff --git a/packages/core/test/filesystem/filesystem.test.ts b/packages/core/test/filesystem/filesystem.test.ts index 31371b0ac7..fdce1b4476 100644 --- a/packages/core/test/filesystem/filesystem.test.ts +++ b/packages/core/test/filesystem/filesystem.test.ts @@ -1,11 +1,12 @@ import { describe, test, expect } from "bun:test" -import { Effect, Layer, FileSystem } from "effect" -import { NodeFileSystem } from "@effect/platform-node" +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 { testEffect } from "../lib/effect" 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) describe("FSUtil", () => { diff --git a/packages/core/test/location-filesystem.test.ts b/packages/core/test/location-filesystem.test.ts index aad2411ad3..2ae716f615 100644 --- a/packages/core/test/location-filesystem.test.ts +++ b/packages/core/test/location-filesystem.test.ts @@ -2,7 +2,6 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" 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 { FileSystem } from "@opencode-ai/core/filesystem" import { Location } from "@opencode-ai/core/location" @@ -14,18 +13,13 @@ import { it } from "./lib/effect" const provide = (directory: string) => Effect.provide( LayerNode.compile( - LayerNode.bind( - FileSystem.node, - Location.node, - makeLocationNode({ - service: Location.Service, - layer: Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make(directory) })), - ), - deps: [], - }), - ), + FileSystem.node, + [ + [ + Location.node, + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), + ], + ], ), ) diff --git a/packages/core/test/location-mutation.test.ts b/packages/core/test/location-mutation.test.ts index e572e54a55..328bd20c88 100644 --- a/packages/core/test/location-mutation.test.ts +++ b/packages/core/test/location-mutation.test.ts @@ -2,7 +2,6 @@ import fs from "fs/promises" import path from "path" import { describe, expect, test } from "bun:test" 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 { Location } from "@opencode-ai/core/location" import { LocationMutation } from "@opencode-ai/core/location-mutation" @@ -14,18 +13,13 @@ import { it } from "./lib/effect" function provide(directory: string) { return Effect.provide( LayerNode.compile( - LayerNode.bind( - LocationMutation.node, - Location.node, - makeLocationNode({ - service: Location.Service, - layer: Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make(directory) })), - ), - deps: [], - }), - ), + LocationMutation.node, + [ + [ + Location.node, + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), + ], + ], ), ) } diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index 6419d7ba1b..20dac6666d 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -1,11 +1,12 @@ import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test" import { Effect, Layer, Ref } from "effect" 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 { Global } from "@opencode-ai/core/global" import { ModelsDev } from "@opencode-ai/core/models-dev" -import { EventV2 } from "@opencode-ai/core/event" import { it } from "./lib/effect" import { readFile, rm, writeFile, utimes, mkdir } from "fs/promises" import path from "path" @@ -90,10 +91,10 @@ const buildLayer = (state: Ref.Ref) => // Layer.fresh is required: ModelsDev.layer is a module-level Layer constant, // and Effect.provide uses a process-global MemoMap by default — without fresh, // every test would reuse the cachedInvalidateWithTTL state from the first run. - Layer.fresh(ModelsDev.layer).pipe( - Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(EventV2.defaultLayer), + Layer.fresh( + AppNodeBuilder.build(ModelsDev.node, [ + [LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))], + ]), ) const writeCacheText = (text: string, mtimeMs?: number) => diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 4c3071c774..ff8e5bdf29 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -3,30 +3,26 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { Catalog } from "@opencode-ai/core/catalog" 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 { Flag } from "@opencode-ai/core/flag/flag" import { Location } from "@opencode-ai/core/location" import { ModelsDev } from "@opencode-ai/core/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 { location } from "../fixture/location" import { testEffect } from "../lib/effect" import { catalogHost, host, integrationHost } from "./host" -const events = EventV2.defaultLayer const locationLayer = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })), ) -const policy = Policy.layer.pipe(Layer.provide(locationLayer)) -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, connections, integrations)), +const layer = AppNodeBuilder.build( + LayerNode.group([Catalog.node, Integration.node, EventV2.node]), + [[Location.node, locationLayer]], ) -const layer = Layer.mergeAll(catalog.pipe(Layer.provide(connections)), integrations, connections, events, locationLayer) const it = testEffect(layer) describe("ModelsDevPlugin", () => { @@ -65,7 +61,7 @@ describe("ModelsDevPlugin", () => { connections: [], }), ]) - }).pipe(Effect.provide(ModelsDev.defaultLayer)), + }).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))), (previous) => Effect.sync(() => { Flag.OPENCODE_MODELS_PATH = previous.path diff --git a/packages/core/test/plugin/variant.test.ts b/packages/core/test/plugin/variant.test.ts index 45476f366c..36630ee6c8 100644 --- a/packages/core/test/plugin/variant.test.ts +++ b/packages/core/test/plugin/variant.test.ts @@ -1,11 +1,9 @@ import { describe, expect } from "bun:test" import { Catalog } from "@opencode-ai/core/catalog" -import { Credential } from "@opencode-ai/core/credential" -import { EventV2 } from "@opencode-ai/core/event" -import { Integration } from "@opencode-ai/core/integration" +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 { ModelV2 } from "@opencode-ai/core/model" -import { Policy } from "@opencode-ai/core/policy" import { VariantPlugin } from "@opencode-ai/core/plugin/variant" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -14,20 +12,15 @@ import { location } from "../fixture/location" import { testEffect } from "../lib/effect" import { catalogHost, host } from "./host" -const events = EventV2.defaultLayer const locationLayer = Layer.succeed( Location.Service, 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( - Layer.mergeAll(catalog.pipe(Layer.provide(connections)), integrations, connections, events, locationLayer), + AppNodeBuilder.build( + Catalog.node, + [[Location.node, locationLayer]], + ), ) describe("VariantPlugin", () => { diff --git a/packages/core/test/project-copy.test.ts b/packages/core/test/project-copy.test.ts index 8c01e92c50..d37f630207 100644 --- a/packages/core/test/project-copy.test.ts +++ b/packages/core/test/project-copy.test.ts @@ -3,9 +3,10 @@ import { $ } from "bun" import fs from "fs/promises" import path from "path" 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 { FSUtil } from "@opencode-ai/core/fs-util" import { Git } from "@opencode-ai/core/git" import { Database } from "@opencode-ai/core/database/database" 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 { 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( - 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) { diff --git a/packages/core/test/reference.test.ts b/packages/core/test/reference.test.ts index b245e90fe0..db61232310 100644 --- a/packages/core/test/reference.test.ts +++ b/packages/core/test/reference.test.ts @@ -1,16 +1,18 @@ import { describe, expect } from "bun:test" 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 { Global } from "@opencode-ai/core/global" import { Reference } from "@opencode-ai/core/reference" import { Repository } from "@opencode-ai/core/repository" import { RepositoryCache } from "@opencode-ai/core/repository-cache" -import { EventV2 } from "@opencode-ai/core/event" import { it } from "./lib/effect" const cache = Layer.mock(RepositoryCache.Service, { ensure: () => Effect.die("unexpected Git materialization"), }) +const referenceLayer = AppNodeBuilder.build(Reference.node, [[RepositoryCache.node, cache]]) describe("Reference", () => { it.effect("registers normalized sources for the owning scope", () => @@ -32,12 +34,7 @@ describe("Reference", () => { yield* Scope.close(scope, Exit.void) expect(yield* references.list()).toEqual([]) - }).pipe( - Effect.provide(Reference.layer), - Effect.provide(cache), - Effect.provide(EventV2.defaultLayer), - Effect.provide(Global.defaultLayer), - ), + }).pipe(Effect.provide(referenceLayer)), ) it.effect("derives Git paths without exposing cache operations", () => @@ -54,13 +51,7 @@ describe("Reference", () => { source, }), ]) - }).pipe( - Effect.scoped, - Effect.provide(Reference.layer), - Effect.provide(cache), - Effect.provide(EventV2.defaultLayer), - Effect.provide(Global.defaultLayer), - ), + }).pipe(Effect.scoped, Effect.provide(referenceLayer)), ) it.effect("preserves configured Git descriptions", () => @@ -82,12 +73,6 @@ describe("Reference", () => { source, }), ]) - }).pipe( - Effect.scoped, - Effect.provide(Reference.layer), - Effect.provide(cache), - Effect.provide(EventV2.defaultLayer), - Effect.provide(Global.defaultLayer), - ), + }).pipe(Effect.scoped, Effect.provide(referenceLayer)), ) }) diff --git a/packages/core/test/repository-cache.test.ts b/packages/core/test/repository-cache.test.ts index a99daea8e2..2dd0ce2502 100644 --- a/packages/core/test/repository-cache.test.ts +++ b/packages/core/test/repository-cache.test.ts @@ -3,12 +3,11 @@ import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" import { Effect, Layer } from "effect" -import { FSUtil } from "@opencode-ai/core/fs-util" -import { Git } from "@opencode-ai/core/git" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Global } from "@opencode-ai/core/global" import { Repository } from "@opencode-ai/core/repository" import { RepositoryCache } from "@opencode-ai/core/repository-cache" -import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { git, gitRemote } from "./fixture/git" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" @@ -89,15 +88,9 @@ describe("RepositoryCache", () => { }) function cacheLayer(root: string) { - const dependencies = Layer.mergeAll( - 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), - ) + return AppNodeBuilder.build(RepositoryCache.node, [ + [Global.node, Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })], + ]) } function withRemote(body: (fixture: Awaited>) => Effect.Effect) { diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 04ebe82fa9..56fddb1f0f 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -4,7 +4,6 @@ import { asc, eq } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" import { LayerNode } from "@opencode-ai/core/effect/layer-node" 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 { EventTable } from "@opencode-ai/core/event/sql" 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 sessionsLayer = AppNodeBuilder.build( - LayerNode.bind( - SessionV2.node, - SessionExecution.node, - makeGlobalNode({ service: SessionExecution.Service, layer: SessionExecution.noopLayer, deps: [] }), - ), + SessionV2.node, + [[SessionExecution.node, SessionExecution.noopLayer]], ) const sessionID = SessionV2.ID.make("ses_projector_test") const created = DateTime.makeUnsafe(0) diff --git a/packages/core/test/session-tool-progress.test.ts b/packages/core/test/session-tool-progress.test.ts index 85dcb604a9..6d07b14657 100644 --- a/packages/core/test/session-tool-progress.test.ts +++ b/packages/core/test/session-tool-progress.test.ts @@ -1,7 +1,8 @@ import { describe, expect } from "bun:test" 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 { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { EventTable } from "@opencode-ai/core/event/sql" 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 { 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 model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") } diff --git a/packages/core/test/skill-discovery.test.ts b/packages/core/test/skill-discovery.test.ts index 7049374f31..e875beb931 100644 --- a/packages/core/test/skill-discovery.test.ts +++ b/packages/core/test/skill-discovery.test.ts @@ -3,7 +3,9 @@ import path from "path" import { describe, expect, test } from "bun:test" import { Effect, Layer } from "effect" 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 { SkillDiscovery } from "@opencode-ai/core/skill/discovery" import { tmpdir } from "./fixture/tmpdir" @@ -27,15 +29,14 @@ async function pull(skills: unknown[], files: Record = {}, cache ), ), ) - const layer = SkillDiscovery.layer.pipe( - Layer.provide(http), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Global.layerWith({ cache: tmp.path })), - ) + const skillDiscoveryLayer = AppNodeBuilder.build(SkillDiscovery.node, [ + [LayerNodePlatform.httpClient, http], + [Global.node, Global.layerWith({ cache: tmp.path })], + ]) const directories = await Effect.runPromise( Effect.gen(function* () { return yield* (yield* SkillDiscovery.Service).pull(base) - }).pipe(Effect.provide(layer)), + }).pipe(Effect.provide(skillDiscoveryLayer)), ) return { tmp, requests, directories } } diff --git a/packages/core/test/skill.test.ts b/packages/core/test/skill.test.ts index 94196fd3bd..0f8e1b3cb3 100644 --- a/packages/core/test/skill.test.ts +++ b/packages/core/test/skill.test.ts @@ -24,9 +24,7 @@ const discovery = Layer.succeed( }), ) const it = testEffect( - AppNodeBuilder.build(LayerNode.group([SkillV2.node, AgentV2.node]), [ - LayerNode.replace(SkillDiscovery.layer, discovery), - ]), + AppNodeBuilder.build(LayerNode.group([SkillV2.node, AgentV2.node]), [[SkillDiscovery.node, discovery]]), ) function write(directory: string, name: string, description: string) { diff --git a/packages/core/test/system-context/builtins.test.ts b/packages/core/test/system-context/builtins.test.ts index fc61a08165..26b9175223 100644 --- a/packages/core/test/system-context/builtins.test.ts +++ b/packages/core/test/system-context/builtins.test.ts @@ -1,7 +1,6 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" 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 { LayerNode } from "@opencode-ai/core/effect/layer-node" 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.bind( - LayerNode.group([SystemContextBuiltIns.node, SystemContextRegistry.node]), - Location.node, - locationNode, -) +const builtInsNode = LayerNode.group([SystemContextBuiltIns.node, SystemContextRegistry.node]) 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( FSUtil.Service, @@ -51,8 +48,9 @@ const instructionFS = Layer.effect( ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) const itWithInstructions = testEffect( AppNodeBuilder.build(builtInsNode, [ - LayerNode.replace(FSUtil.layer, instructionFS), - LayerNode.replace(Global.layer, Global.layerWith({ config: "/global" })), + [Location.node, locationLayer], + [FSUtil.node, instructionFS], + [Global.node, Global.layerWith({ config: "/global" })], ]), ) diff --git a/packages/core/test/tool-output-store.test.ts b/packages/core/test/tool-output-store.test.ts index f0504e9759..a2d132a74b 100644 --- a/packages/core/test/tool-output-store.test.ts +++ b/packages/core/test/tool-output-store.test.ts @@ -1,6 +1,8 @@ import { describe, expect } from "bun:test" import path from "path" 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 { Global } from "@opencode-ai/core/global" import { Config } from "@opencode-ai/core/config" @@ -28,14 +30,14 @@ const withStore = ( }), ) : Layer.empty - const store = ToolOutputStore.layer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(global), - Layer.provide(configured), - ) + + const store = AppNodeBuilder.build(LayerNode.group([ToolOutputStore.node, FSUtil.node]), [ + [Global.node, global], + [Config.node, configured], + ]) return Effect.gen(function* () { 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]()), ) @@ -185,11 +187,11 @@ describe("ToolOutputStore", () => { writeFileString: () => Effect.never, }) }), - ).pipe(Layer.provide(FSUtil.defaultLayer)) - const store = ToolOutputStore.layer.pipe( - Layer.provide(blockedFilesystem), - Layer.provide(Global.layerWith({ data: root.path })), - ) + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) + const store = AppNodeBuilder.build(ToolOutputStore.nodeWithoutConfig, [ + [Global.node, Global.layerWith({ data: root.path })], + [FSUtil.node, blockedFilesystem], + ]) const exit = yield* Effect.gen(function* () { const service = yield* ToolOutputStore.Service const fiber = yield* service diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index 98105a8dc0..5ba2990405 100644 --- a/packages/core/test/tool-question.test.ts +++ b/packages/core/test/tool-question.test.ts @@ -1,10 +1,13 @@ import { describe, expect } from "bun:test" 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 { QuestionV2 } from "@opencode-ai/core/question" import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { QuestionTool } from "@opencode-ai/core/tool/question" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { testEffect } from "./lib/effect" import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" @@ -28,7 +31,10 @@ const permission = Layer.succeed( 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( QuestionV2.Service, QuestionV2.Service.of({ diff --git a/packages/core/test/tool-read-filesystem.test.ts b/packages/core/test/tool-read-filesystem.test.ts index c897786e89..e9970fb975 100644 --- a/packages/core/test/tool-read-filesystem.test.ts +++ b/packages/core/test/tool-read-filesystem.test.ts @@ -1,12 +1,13 @@ import { describe, expect } from "bun:test" -import { NodeFileSystem } from "@effect/platform-node" 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 { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem" 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 fs = yield* FSUtil.Service const files = yield* FileSystem.FileSystem diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index 8b12c8dc0d..acb3fe527b 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -2,6 +2,8 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" 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 { PermissionV2 } from "@opencode-ai/core/permission" 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 { SkillTool } from "@opencode-ai/core/tool/skill" import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { tmpdir } from "./fixture/tmpdir" import { it } from "./lib/effect" import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" @@ -63,11 +66,14 @@ describe("SkillTool", () => { 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( Layer.provide(registry), Layer.provide(permission), - Layer.provide(FSUtil.defaultLayer), + Layer.provide(LayerNode.compile(FSUtil.node)), Layer.provide(skills), ) const layer = Layer.mergeAll(permission, skills, registry, tool) diff --git a/packages/core/test/util/effect-flock.test.ts b/packages/core/test/util/effect-flock.test.ts index 0ec17c1e63..a0f737a998 100644 --- a/packages/core/test/util/effect-flock.test.ts +++ b/packages/core/test/util/effect-flock.test.ts @@ -3,9 +3,10 @@ import { spawn } from "child_process" import fs from "fs/promises" import path from "path" import os from "os" -import { Cause, Effect, Exit, Layer } from "effect" +import { Cause, Effect, Exit } from "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 { Global } from "@opencode-ai/core/global" import { Hash } from "@opencode-ai/core/util/hash" @@ -109,7 +110,7 @@ const testGlobal = Global.layerWith({ 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 diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index f760989dc7..f736becca7 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -176,12 +176,12 @@ const root = LayerNode.group([ CrossSpawnSpawner.node, ]) const replacements = [ - LayerNode.replace(SessionSummary.layer, summary), - LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer({ experimentalEventSystem: true })), -] + [SessionSummary.node, summary], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })], +] as const const env = LayerNode.compile( 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) @@ -208,9 +208,7 @@ const providerErrorLLM = Layer.succeed( ) const providerErrorEnv = LayerNode.compile( root, - new Map( - [...replacements, LayerNode.replace(LLM.layer, providerErrorLLM)].map((item) => [item.source, item.replacement]), - ), + [...replacements, [LLM.node, providerErrorLLM]], ) const itProviderError = testEffect(providerErrorEnv) @@ -230,9 +228,7 @@ const fragmentFailureLLM = Layer.succeed( ) const fragmentFailureEnv = LayerNode.compile( root, - new Map( - [...replacements, LayerNode.replace(LLM.layer, fragmentFailureLLM)].map((item) => [item.source, item.replacement]), - ), + [...replacements, [LLM.node, fragmentFailureLLM]], ) const itFragmentFailure = testEffect(fragmentFailureEnv) diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index b7cb5b72de..d21a3841be 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -89,13 +89,11 @@ const root = LayerNode.group([ const it = testEffect( LayerNode.compile( root, - new Map( - [ - LayerNode.replace(MCP.layer, mcp), - LayerNode.replace(LSP.layer, lsp), - LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer({ experimentalEventSystem: true })), - ].map((item) => [item.source, item.replacement]), - ), + [ + [MCP.node, mcp], + [LSP.node, lsp], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })], + ], ), ) diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index b743710d0d..3824b1b802 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect } from "bun:test" 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 { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -34,15 +34,15 @@ const json = (req: Parameters[0], body: unkno const none = HttpClient.make(() => Effect.die("unexpected http call")) 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( LayerNode.group([ShareNext.node, AccountRepo.node]), - new Map([[replacement.source, replacement.replacement]]), + [replacement], ) } 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( LayerNode.group([ ShareNext.node, @@ -52,7 +52,7 @@ function integrationLayer(client: HttpClient.HttpClient) { AccountRepo.node, Database.node, ]), - new Map([[replacement.source, replacement.replacement]]), + [replacement], ) } diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index d364be60ba..6164495549 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -50,20 +50,15 @@ const brokenPluginLayer = Layer.succeed( const root = LayerNode.group([ToolRegistry.node, Agent.node]) const replacements = [ - LayerNode.replace(Config.layer, configLayer), - LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer()), -] + [Config.node, configLayer], + [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( LayerNode.compile( root, - new Map( - [...replacements, LayerNode.replace(Plugin.layer, brokenPluginLayer)].map((item) => [ - item.source, - item.replacement, - ]), - ), + [...replacements, [Plugin.node, brokenPluginLayer]], ), ) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 45841ccb33..281565161f 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -50,7 +50,8 @@ export function createEmbeddedRoutes() { function makeRoutes(auth: Layer.Layer) { const serviceLayer = AppNodeBuilder.build( - LayerNode.bind(applicationServices, SessionExecution.node, SessionExecutionLocal.node), + applicationServices, + [[SessionExecution.node, SessionExecutionLocal.node]], ) return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(