refactor(core): make node build bind maps conditionally (#34218)

This commit is contained in:
James Long
2026-06-27 11:09:07 -04:00
committed by GitHub
parent f5a0b920a2
commit ecc5c44d9a
3 changed files with 149 additions and 110 deletions
@@ -3,6 +3,52 @@ import { LayerNode } from "./layer-node"
type AnyNode = LayerNode.Node<unknown, unknown, any> type AnyNode = LayerNode.Node<unknown, unknown, any>
type RuntimeLayer = Layer.Layer<never, unknown, unknown> type RuntimeLayer = Layer.Layer<never, unknown, unknown>
type Visit<Result> = (node: AnyNode, context: VisitContext<Result>) => Result
type VisitContext<Result> = {
readonly cache: Map<AnyNode, Result>
readonly visit: (node: AnyNode) => Result
}
function walk<Result>(
root: AnyNode,
visit: Visit<Result>,
options: {
readonly cache?: Map<AnyNode, Result>
readonly resolve?: (node: AnyNode) => AnyNode
readonly detectCycles?: boolean
} = {},
) {
const cache = options.cache ?? new Map<AnyNode, Result>()
const visiting = new Set<AnyNode>()
const stack: AnyNode[] = []
const recur = (node: AnyNode): Result => {
const target = options.resolve?.(node) ?? node
const cached = cache.get(target)
if (cached !== undefined || cache.has(target)) return cached!
if (options.detectCycles !== false && visiting.has(target)) {
const start = stack.indexOf(target)
throw new Error(
`Cycle detected in layer tree: ${[...stack.slice(start), target].map((item) => item.name).join(" -> ")}`,
)
}
visiting.add(target)
stack.push(target)
try {
const result = visit(target, { cache, visit: recur })
if (!cache.has(target)) cache.set(target, result)
return result
} finally {
stack.pop()
visiting.delete(target)
}
}
return recur(root)
}
export function hoist<A, E, T extends LayerNode.Tag>( export function hoist<A, E, T extends LayerNode.Tag>(
root: LayerNode.Node<A, E, any>, root: LayerNode.Node<A, E, any>,
@@ -11,54 +57,28 @@ export function hoist<A, E, T extends LayerNode.Tag>(
readonly node: LayerNode.Node<A, E> readonly node: LayerNode.Node<A, E>
readonly hoisted: LayerNode.Node<unknown, E> readonly hoisted: LayerNode.Node<unknown, E>
} { } {
const visited = new Map<AnyNode, AnyNode>()
const hoisted = new Map<string, AnyNode>() const hoisted = new Map<string, AnyNode>()
const visiting = new Set<AnyNode>()
const stack: AnyNode[] = []
const visit = (node: AnyNode): AnyNode => { const node = walk<AnyNode>(root, (node, context) => {
if (node.kind === "group") { if (node.kind === "group") {
return { ...node, dependencies: node.dependencies.map(visit) } return { ...node, dependencies: node.dependencies.map(context.visit) }
} }
const existingNode = visited.get(node)
if (existingNode) return existingNode
if (node.tag === tag) { if (node.tag === tag) {
const existing = hoisted.get(node.name) const existing = hoisted.get(node.name)
if (existing && existing !== node) { if (existing && existing !== node) {
throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`) throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
} }
hoisted.set(node.name, node) hoisted.set(node.name, node)
const empty = LayerNode.group([]) return LayerNode.group([])
visited.set(node, empty)
return empty
} }
if (node.kind === "unbound") { if (node.kind === "unbound") {
return node return node
} }
return { ...node, dependencies: node.dependencies.map(context.visit) }
if (visiting.has(node)) { })
const start = stack.indexOf(node)
throw new Error(
`Cycle detected in layer tree: ${[...stack.slice(start), node].map((item) => item.name).join(" -> ")}`,
)
}
visiting.add(node)
stack.push(node)
try {
const dependencies = node.dependencies.map(visit)
const clone = { ...node, dependencies }
visited.set(node, clone)
return clone
} finally {
stack.pop()
visiting.delete(node)
}
}
return { return {
node: visit(root) as LayerNode.Node<A, E>, node: node as LayerNode.Node<A, E>,
hoisted: LayerNode.group(Array.from(hoisted.values())) as LayerNode.Node<unknown, E>, hoisted: LayerNode.group(Array.from(hoisted.values())) as LayerNode.Node<unknown, E>,
} }
} }
@@ -68,24 +88,32 @@ export function compile<A, E>(
replacements?: ReadonlyMap<Layer.Any, Layer.Any>, replacements?: ReadonlyMap<Layer.Any, Layer.Any>,
): Layer.Layer<A, E> { ): Layer.Layer<A, E> {
const cache = new Map<AnyNode, RuntimeLayer>() const cache = new Map<AnyNode, RuntimeLayer>()
const compileNode = (node: AnyNode): RuntimeLayer => { const compileNode = (node: AnyNode) =>
if (node.kind === "unbound") throw new Error(`Unbound layer node: ${node.name}`) walk<RuntimeLayer>(
const cached = cache.get(node) node,
if (cached) return cached (node, context) => {
const dependencies = node.dependencies.flatMap(flatten).map(compileNode) if (node.kind === "unbound") throw new Error(`Unbound layer node: ${node.name}`)
const implementation = (replacements?.get(node.implementation!) ?? node.implementation!) as RuntimeLayer const dependencies = node.dependencies.flatMap(flatten).map(context.visit)
const layer = const implementation = (replacements?.get(node.implementation!) ?? node.implementation!) as RuntimeLayer
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.set(node, layer) },
return layer { cache },
} )
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>
} }
export function hasUnbound(root: LayerNode.Node<unknown, unknown, any>, source: AnyNode): boolean {
if (source.kind !== "unbound") throw new Error(`Cannot check non-unbound layer node: ${source.name}`)
return walk<boolean>(root, (node, context) => {
if (node === source) return true
return node.dependencies.some(context.visit)
})
}
export function bind<A, E, T extends LayerNode.Tag | undefined>( export function bind<A, E, T extends LayerNode.Tag | undefined>(
root: LayerNode.Node<A, E, T>, root: LayerNode.Node<A, E, T>,
source: AnyNode, source: AnyNode,
@@ -98,17 +126,18 @@ export function bind<A, E, T extends LayerNode.Tag | undefined>(
if (source.tag !== replacement.tag) { if (source.tag !== replacement.tag) {
throw new Error(`Cannot bind ${source.name} across tags`) throw new Error(`Cannot bind ${source.name} across tags`)
} }
const visited = new Map<AnyNode, AnyNode>() return walk<AnyNode>(
const visit = (node: AnyNode): AnyNode => { root,
if (node === source) return replacement (target, context) => {
const existing = visited.get(node) if (target.kind === "unbound") return target
if (existing) return existing const dependencies: AnyNode[] = []
if (node.kind === "unbound") return node const clone = { ...target, dependencies }
const clone = { ...node, dependencies: node.dependencies.map(visit) } context.cache.set(target, clone)
visited.set(node, clone) dependencies.push(...target.dependencies.map(context.visit))
return clone return clone
} },
return visit(root) as LayerNode.Node<A, E, T> { detectCycles: false, resolve: (node) => (node === source ? replacement : node) },
) as LayerNode.Node<A, E, T>
} }
function flatten(node: AnyNode): readonly AnyNode[] { function flatten(node: AnyNode): readonly AnyNode[] {
+6
View File
@@ -7,6 +7,12 @@ import { makeGlobalNode } from "./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?: readonly LayerNode.Replacement[]) {
const replacementMap = new Map(replacements?.map((item) => [item.source, item.replacement])) const replacementMap = new Map(replacements?.map((item) => [item.source, item.replacement]))
if (!LayerNodeTree.hasUnbound(root, LocationServiceMap.node)) {
// If the location service map is not needed, we shouldn't pull it
// in. Compile the graph normally
return LayerNodeTree.compile(root, replacementMap)
}
const locationMap = buildLocationServiceMap(replacementMap) const locationMap = buildLocationServiceMap(replacementMap)
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] }) const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
@@ -1,21 +1,72 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer, LayerMap, Option } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode, LayerNodeTree } from "@opencode-ai/core/effect/layer-node"
import { Node } from "@opencode-ai/core/effect/node" import { Node } from "@opencode-ai/core/effect/node"
import { NodeBuild } from "@opencode-ai/core/effect/node-build" import { NodeBuild } from "@opencode-ai/core/effect/node-build"
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 { Project } from "@opencode-ai/core/project" import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { tmpdir } from "../../fixture/tmpdir" import { tmpdir } from "../../fixture/tmpdir"
class Value extends Context.Service<Value, { readonly value: string }>()("test/TagValue") {} class Value extends Context.Service<Value, { readonly value: string }>()("test/TagValue") {}
class Result extends Context.Service<Result, { readonly value: string }>()("test/TagResult") {} class Result extends Context.Service<Result, { readonly value: string }>()("test/TagResult") {}
class Left extends Context.Service<Left, { readonly value: string }>()("test/TagLeft") {} class CycleA extends Context.Service<CycleA, {}>()("test/NodeBuildA") {}
class Right extends Context.Service<Right, { readonly value: string }>()("test/TagRight") {} class CycleB extends Context.Service<CycleB, { readonly directory: AbsolutePath }>()("test/NodeBuildB") {}
class Last extends Context.Service<Last, { readonly value: string }>()("test/TagLast") {}
describe("node build", () => { describe("node build", () => {
test("does not build a location service map when the graph does not require it", async () => {
const result = Node.makeGlobalNode({
service: Result,
layer: Layer.succeed(Result, Result.of({ value: "plain" })),
deps: [],
})
const layer = NodeBuild.build(LayerNode.group([result]))
const program = Effect.gen(function* () {
expect(Option.isNone(yield* Effect.serviceOption(LocationServiceMap.Service))).toBe(true)
return (yield* Result).value
}).pipe(Effect.provide(layer))
expect(await Effect.runPromise(program)).toBe("plain")
})
test("detects cycles through a bound location service map", () => {
const a = Node.makeGlobalNode({
service: CycleA,
layer: Layer.effect(CycleA, Effect.as(LocationServiceMap.Service, CycleA.of({}))),
deps: [LocationServiceMap.node],
})
const b = Node.makeGlobalNode({
service: CycleB,
layer: Layer.effect(
CycleB,
Effect.map(CycleA, () => CycleB.of({ directory: AbsolutePath.make(process.cwd()) })),
),
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<LayerMap.LayerMap<Location.Ref, LocationServices, LocationError>, never, CycleB>
const mapLayer = Layer.effect(LocationServiceMap.Service, mapEffect)
const map = Node.makeGlobalNode({ service: LocationServiceMap.Service, layer: mapLayer, deps: [b] })
const graph = LayerNodeTree.bind(LayerNode.group([a]), LocationServiceMap.node, map)
expect(() => NodeBuild.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 () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
let acquisitions = 0 let acquisitions = 0
@@ -37,6 +88,7 @@ describe("node build", () => {
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
expect(Option.isSome(yield* Effect.serviceOption(LocationServiceMap.Service))).toBe(true)
return yield* Location.Service.pipe(Effect.provide(locations.get(ref))) return yield* Location.Service.pipe(Effect.provide(locations.get(ref)))
}).pipe(Effect.provide(layer)) }).pipe(Effect.provide(layer))
@@ -62,58 +114,10 @@ describe("node build", () => {
}) })
const serviceLayer = NodeBuild.build(LayerNode.group([result])) const serviceLayer = NodeBuild.build(LayerNode.group([result]))
const program = Effect.gen(function* () { const program = Effect.gen(function* () {
expect(Option.isNone(yield* Effect.serviceOption(LocationServiceMap.Service))).toBe(true)
return (yield* Result).value return (yield* Result).value
}).pipe(Effect.provide(serviceLayer)) }).pipe(Effect.provide(serviceLayer))
expect(await Effect.runPromise(program)).toBe("value") expect(await Effect.runPromise(program)).toBe("value")
}) })
test("rebinds same-tag providers without reacquiring them", async () => {
let firstAcquisitions = 0
const tags = LayerNode.tags({ global: [] })
const global = tags.make("global")
const first = global({
service: Value,
layer: Layer.effect(
Value,
Effect.sync(() => {
firstAcquisitions++
return Value.of({ value: "first" })
}),
),
deps: [],
})
const second = global({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "second" })), deps: [] })
const left = global({
service: Left,
layer: Layer.effect(
Left,
Effect.map(Value, (value) => Left.of({ value: value.value })),
),
deps: [first],
})
const right = global({
service: Right,
layer: Layer.effect(
Right,
Effect.map(Value, (value) => Right.of({ value: value.value })),
),
deps: [second],
})
const last = global({
service: Last,
layer: Layer.effect(
Last,
Effect.map(Value, (value) => Last.of({ value: value.value })),
),
deps: [first],
})
const layer = NodeBuild.build(LayerNode.group([left, right, last])) as Layer.Layer<Left | Right | Last>
const values = Effect.gen(function* () {
return [(yield* Left).value, (yield* Right).value, (yield* Last).value]
}).pipe(Effect.provide(layer))
expect(await Effect.runPromise(values)).toEqual(["first", "second", "first"])
expect(firstAcquisitions).toBe(1)
})
}) })