refactor(core): make node build bind maps conditionally (#34218)
This commit is contained in:
@@ -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) =>
|
||||||
|
walk<RuntimeLayer>(
|
||||||
|
node,
|
||||||
|
(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 cached = cache.get(node)
|
const dependencies = node.dependencies.flatMap(flatten).map(context.visit)
|
||||||
if (cached) return cached
|
|
||||||
const dependencies = node.dependencies.flatMap(flatten).map(compileNode)
|
|
||||||
const implementation = (replacements?.get(node.implementation!) ?? node.implementation!) as RuntimeLayer
|
const implementation = (replacements?.get(node.implementation!) ?? node.implementation!) as RuntimeLayer
|
||||||
const layer =
|
return dependencies.length === 0
|
||||||
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[] {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user