Skip to content

Commit 0e731c2

Browse files
authored
refactor(core): support tiered layer nodes (anomalyco#33937)
1 parent 21b78c4 commit 0e731c2

80 files changed

Lines changed: 1132 additions & 601 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/core/src/cross-spawn-spawner.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,6 @@ export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSyste
503503
)
504504

505505
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
506-
export const node = LayerNode.make(layer, [filesystem, path])
506+
export const node = LayerNode.make({ service: ChildProcessSpawner, layer, deps: [filesystem, path] })
507507

508508
export * as CrossSpawnSpawner from "./cross-spawn-spawner"

packages/core/src/database/database.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,4 @@ export const defaultLayer = Layer.unwrap(
6060
}),
6161
).pipe(Layer.provide(Global.defaultLayer))
6262

63-
export const node = LayerNode.make(layerFromPath(path()), [])
63+
export const node = LayerNode.make({ service: Service, layer: layerFromPath(path()), deps: [] })
Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import { NodeFileSystem, NodePath } from "@effect/platform-node"
22
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
3+
import { FileSystem, Path } from "effect"
34
import { FetchHttpClient } from "effect/unstable/http"
5+
import { HttpClient } from "effect/unstable/http"
46
import { LayerNode } from "./layer-node"
57

6-
export const filesystem = LayerNode.make(NodeFileSystem.layer, [])
7-
export const path = LayerNode.make(NodePath.layer, [])
8-
export const httpClient = LayerNode.make(FetchHttpClient.layer, [])
9-
export const requestExecutor = LayerNode.make(RequestExecutor.layer, [httpClient])
10-
export const llmClient = LayerNode.make(LLMClient.layer, [requestExecutor])
8+
export const filesystem = LayerNode.make({ service: FileSystem.FileSystem, layer: NodeFileSystem.layer, deps: [] })
9+
export const path = LayerNode.make({ service: Path.Path, layer: NodePath.layer, deps: [] })
10+
export const httpClient = LayerNode.make({ service: HttpClient.HttpClient, layer: FetchHttpClient.layer, deps: [] })
11+
export const requestExecutor = LayerNode.make({
12+
service: RequestExecutor.Service,
13+
layer: RequestExecutor.layer,
14+
deps: [httpClient],
15+
})
16+
export const llmClient = LayerNode.make({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] })
1117

1218
export * as LayerNodePlatform from "./layer-node-platform"
Lines changed: 194 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { Layer } from "effect"
1+
import { Brand, Context, Layer } from "effect"
22

33
type RuntimeLayer = Layer.Layer<never, unknown, unknown>
4-
type AnyNode = Node<unknown, unknown>
5-
type NodeList = readonly [] | readonly [AnyNode, ...AnyNode[]]
6-
type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown> ? A : never
7-
type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E> ? E : never
4+
type AnyNode = Node<unknown, unknown, any>
5+
type NodeList<Item extends AnyNode = AnyNode> = readonly [] | readonly [Item, ...Item[]]
6+
type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown, any> ? A : never
7+
type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E, any> ? E : never
88
type Missing<Required, Dependencies extends NodeList> = Exclude<Required, Output<Dependencies[number]>>
99
type CheckDependencies<Implementation extends Layer.Any, Dependencies extends NodeList> = [
1010
Missing<Layer.Services<Implementation>, Dependencies>,
@@ -14,89 +14,235 @@ type CheckDependencies<Implementation extends Layer.Any, Dependencies extends No
1414
declare const $OutputType: unique symbol
1515
declare const $ErrorType: unique symbol
1616

17-
export type Node<A, E = never> = {
17+
export type Tier<Name extends string = string> = Name & Brand.Brand<"LayerNode.Tier">
18+
19+
const makeTier = Brand.nominal<Tier>()
20+
21+
export type Node<A, E = never, T extends Tier | undefined = undefined> = {
1822
readonly kind: "layer" | "group"
23+
readonly name: string
24+
readonly service?: Context.Service.Any
1925
readonly implementation?: Layer.Any
2026
readonly dependencies: readonly AnyNode[]
27+
readonly tier?: T
2128
readonly [$OutputType]?: () => A
2229
readonly [$ErrorType]?: () => E
2330
}
2431

25-
export function make<const Implementation extends Layer.Any, const Items extends NodeList>(
26-
implementation: Implementation,
27-
dependencies: Items & CheckDependencies<Implementation, NoInfer<Items>>,
28-
): Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>> {
29-
return { kind: "layer", implementation: implementation as Layer.Any, dependencies }
32+
type NodeIdentity =
33+
| { readonly service: Context.Service.Any; readonly name?: never }
34+
| { readonly name: string; readonly service?: never }
35+
type DistributiveOmit<A, K extends PropertyKey> = A extends unknown ? Omit<A, K> : never
36+
37+
type NodeInput<
38+
Implementation extends Layer.Any,
39+
Items extends NodeList,
40+
T extends Tier | undefined = undefined,
41+
> = NodeIdentity & {
42+
readonly layer: Implementation
43+
readonly deps: Items & CheckDependencies<Implementation, NoInfer<Items>>
44+
readonly tier?: T
45+
}
46+
47+
export function make<
48+
const Implementation extends Layer.Any,
49+
const Items extends NodeList,
50+
const T extends Tier | undefined = undefined,
51+
>(
52+
input: NodeInput<Implementation, Items, T>,
53+
): Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, T> {
54+
return {
55+
kind: "layer",
56+
name: input.service !== undefined ? input.service.key : input.name,
57+
service: input.service,
58+
implementation: input.layer,
59+
dependencies: input.deps,
60+
tier: input.tier,
61+
}
3062
}
3163

3264
export function group<const Items extends NodeList>(
3365
dependencies: Items,
3466
): Node<Output<Items[number]>, Error<Items[number]>> {
35-
return { kind: "group", dependencies }
67+
return { kind: "group", name: "group", dependencies }
3668
}
3769

38-
export type Replacement<A = unknown> = {
39-
readonly source: Node<A, unknown>
40-
readonly replacement: Node<A, unknown>
70+
type AllowedTierNames<Names extends readonly string[], Name extends Names[number]> = Names extends readonly [
71+
infer Head extends string,
72+
...infer Tail extends readonly string[],
73+
]
74+
? Head extends Name
75+
? Head | Tail[number]
76+
: AllowedTierNames<Tail, Name>
77+
: never
78+
79+
type NodeInTiers<Names extends string> = Node<unknown, unknown, Tier<Names>>
80+
81+
export interface Tiers<Names extends readonly [string, ...string[]]> {
82+
readonly names: Names
83+
readonly values: { readonly [K in Names[number]]: Tier<K> }
84+
readonly make: <Name extends Names[number]>(
85+
name: Name,
86+
) => <
87+
const Implementation extends Layer.Any,
88+
const Items extends NodeList<NodeInTiers<AllowedTierNames<Names, Name>>>,
89+
>(
90+
input: DistributiveOmit<NodeInput<Implementation, Items, Tier<Name>>, "tier">,
91+
) => Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, Tier<Name>>
92+
}
93+
94+
export function tiers<const Names extends readonly [string, ...string[]]>(names: Names): Tiers<Names> {
95+
const values = Object.fromEntries(names.map((name) => [name, makeTier(name)])) as Tiers<Names>["values"]
96+
return {
97+
names,
98+
values,
99+
make: ((name: Names[number]) => (input: DistributiveOmit<NodeInput<Layer.Any, NodeList, Tier>, "tier">) =>
100+
make({ ...input, tier: values[name] })) as Tiers<Names>["make"],
101+
}
102+
}
103+
104+
const defaultTiers = tiers(["untiered"])
105+
const untiered = defaultTiers.values.untiered
106+
107+
export type Replacement = {
108+
readonly source: Layer.Any
109+
readonly replacement: Layer.Any
41110
}
42111

43112
type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<ReplacementError, SourceError>] extends [never]
44113
? unknown
45114
: { readonly "New replacement errors": Exclude<ReplacementError, SourceError> }
46115

47-
export function replaceWithNode<A, E, E2>(
48-
source: Node<A, E>,
49-
replacement: Node<NoInfer<A>, E2> & CheckReplacementErrors<E, NoInfer<E2>>,
50-
): Replacement<A> {
116+
export function replace<A, E, R, E2>(
117+
source: Layer.Layer<A, E, R>,
118+
replacement: Layer.Layer<NoInfer<A>, E2, never> & CheckReplacementErrors<E, NoInfer<E2>>,
119+
): Replacement {
51120
return { source, replacement }
52121
}
53122

54-
export function replace<A, E, E2>(
55-
source: Node<A, E>,
56-
replacement: Layer.Layer<NoInfer<A>, E2, never> & CheckReplacementErrors<E, NoInfer<E2>>,
57-
): Replacement<A> {
58-
return { source, replacement: make(replacement as Layer.Layer<A, E2>, []) }
123+
export function buildLayer<
124+
A,
125+
E,
126+
const Names extends readonly [string, ...string[]] = readonly ["untiered"],
127+
const Built extends Layer.Any = Layer.Layer<never, never, never>,
128+
>(
129+
node: Node<A, E, any>,
130+
options?: {
131+
readonly tiers?: Tiers<Names>
132+
readonly buildTier?: (tier: Names[number], layers: readonly Layer.Any[]) => Built
133+
readonly replacements?: readonly Replacement[]
134+
},
135+
): Layer.Layer<A | Layer.Success<Built>, E | Layer.Error<Built>, never> {
136+
const tiers = options?.tiers ?? (defaultTiers as unknown as Tiers<Names>)
137+
const replacementMap = new Map(options?.replacements?.map((item) => [item.source, item.replacement]))
138+
const plans = plan(node, tiers, replacementMap)
139+
const layers: RuntimeLayer[] = tiers.names.map((name) => {
140+
const tier = tiers.values[name as Names[number]]
141+
const layers = plans.get(tier) ?? []
142+
return (options?.buildTier?.(name, layers) ?? combine(layers)) as RuntimeLayer
143+
})
144+
if (layers.length === 0) return Layer.empty as never
145+
return layers.slice(1).reduce((result, layer) => result.pipe(Layer.provideMerge(layer)), layers[0]) as never
146+
}
147+
148+
export function combine(layers: readonly Layer.Any[]): RuntimeLayer {
149+
return layers.reduce<RuntimeLayer>(
150+
(result, layer) => (layer as RuntimeLayer).pipe(Layer.provideMerge(result)),
151+
Layer.empty as RuntimeLayer,
152+
)
59153
}
60154

61-
export function buildLayer<A, E>(node: Node<A, E>, options?: { readonly replacements?: readonly Replacement[] }) {
62-
const replacements = new Map(options?.replacements?.map((item) => [item.source, item.replacement]))
63-
const cache = new Map<AnyNode, RuntimeLayer>()
155+
function plan(
156+
root: AnyNode,
157+
tiers: Tiers<readonly [string, ...string[]]>,
158+
replacements: ReadonlyMap<Layer.Any, Layer.Any>,
159+
) {
160+
const indexes = new Map(tiers.names.map((name, index) => [tiers.values[name], index]))
161+
const plans = new Map<Tier, Layer.Any[]>()
162+
const activeImplementations = new Map<Tier, Map<string, AnyNode>>()
163+
const serviceTiers = new Map<string, Tier>()
64164
const visiting = new Set<AnyNode>()
65165
const stack: AnyNode[] = []
66-
const ids = new Map<AnyNode, number>()
166+
const boundaryVisited = new Map<AnyNode, Set<Tier>>()
167+
const boundaryServices = new Map<Tier, Map<string, AnyNode>>()
168+
169+
const validateBoundary = (node: AnyNode, origin: Tier) => {
170+
const checked = boundaryVisited.get(node) ?? new Set<Tier>()
171+
boundaryVisited.set(node, checked)
172+
if (checked.has(origin)) return false
173+
checked.add(origin)
174+
const services = boundaryServices.get(origin) ?? new Map<string, AnyNode>()
175+
boundaryServices.set(origin, services)
176+
const key = node.name
177+
const existing = services.get(key)
178+
if (existing && existing !== node) {
179+
throw new Error(`Tier ${origin} has conflicting implementations for ${key}`)
180+
}
181+
services.set(key, node)
182+
return true
183+
}
184+
185+
const visit = (node: AnyNode, currentTier?: Tier, origins: readonly Tier[] = []) => {
186+
if (node.kind === "group") {
187+
node.dependencies.forEach((dependency) => visit(dependency, currentTier, origins))
188+
return
189+
}
190+
191+
const tier = node.tier ?? untiered
192+
if (!indexes.has(tier)) throw new Error(`Node ${node.name} is not in the tier configuration`)
193+
const key = node.name
194+
const serviceTier = serviceTiers.get(key)
195+
if (serviceTier && serviceTier !== tier) {
196+
throw new Error(`Service ${key} belongs to both tier ${serviceTier} and tier ${tier}`)
197+
}
198+
serviceTiers.set(key, tier)
199+
const nextOrigins = [...origins]
200+
if (currentTier) {
201+
const current = indexes.get(currentTier)!
202+
const required = indexes.get(tier)!
203+
if (required < current) {
204+
throw new Error(`Tier ${currentTier} cannot depend on lower tier ${tier}`)
205+
}
206+
if (required > current) nextOrigins.push(currentTier)
207+
}
208+
const unseenOrigins = nextOrigins.filter((origin) => validateBoundary(node, origin))
209+
210+
// A node may need to be emitted more than once because the final output is a
211+
// flat list of layers applied with Layer.provideMerge. If another node for
212+
// the same service was emitted afterward, this node is no longer the active
213+
// implementation for subsequent consumers. Re-emitting restores the intended
214+
// implementation ordering while Effect memoization avoids reacquiring the layer.
215+
const implementations = activeImplementations.get(tier) ?? new Map<string, AnyNode>()
216+
activeImplementations.set(tier, implementations)
217+
if (implementations.get(key) === node && unseenOrigins.length === 0) return
67218

68-
const visit = (input: AnyNode): RuntimeLayer => {
69-
const node = replacements.get(input) ?? input
70-
const cached = cache.get(node)
71-
if (cached) return cached
72219
if (visiting.has(node)) {
73220
const start = stack.indexOf(node)
74-
const cycle = [...stack.slice(start), node].map((item) => `${item.kind}#${ids.get(item)}`).join(" -> ")
75-
throw new Error(`Cycle detected in app graph: ${cycle}`)
221+
throw new Error(
222+
`Cycle detected in layer graph: ${[...stack.slice(start), node].map((item) => item.name).join(" -> ")}`,
223+
)
76224
}
77-
if (!ids.has(node)) ids.set(node, ids.size + 1)
225+
78226
visiting.add(node)
79227
stack.push(node)
80228
try {
81-
const dependencies = node.dependencies.map(visit)
82-
const nonEmpty = dependencies as [RuntimeLayer, ...RuntimeLayer[]]
83-
const result =
84-
node.kind === "group"
85-
? dependencies.length === 0
86-
? Layer.empty
87-
: Layer.mergeAll(...nonEmpty)
88-
: dependencies.length === 0
89-
? (node.implementation as RuntimeLayer)
90-
: Layer.provide(node.implementation as RuntimeLayer, nonEmpty)
91-
cache.set(node, result)
92-
return result
229+
node.dependencies.forEach((dependency) => visit(dependency, tier, unseenOrigins))
230+
const layers = plans.get(tier) ?? []
231+
plans.set(tier, layers)
232+
layers.push(replacements.get(node.implementation!) ?? node.implementation!)
233+
implementations.set(key, node)
93234
} finally {
94235
stack.pop()
95236
visiting.delete(node)
96237
}
97238
}
98239

99-
return visit(node) as unknown as Layer.Layer<A, E, never>
240+
visit(root)
241+
return plans
242+
}
243+
244+
function requireTier(node: AnyNode, indexes: ReadonlyMap<Tier, number>) {
245+
if (!node.tier || !indexes.has(node.tier)) throw new Error(`Node ${node.name} is not in the tier configuration`)
100246
}
101247

102248
export * as LayerNode from "./layer-node"
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { LayerNode } from "./layer-node"
2+
3+
export const tiers = LayerNode.tiers(["location", "global"])
4+
5+
export type GlobalNode<A, E = never> = LayerNode.Node<A, E, (typeof tiers.values)["global"]>
6+
export type LocationNode<A, E = never> = LayerNode.Node<A, E, (typeof tiers.values)["location"]>
7+
8+
export const makeGlobalNode = tiers.make("global")
9+
export const makeLocationNode = tiers.make("location")
10+
11+
export * as ScopedNode from "./scoped-node"

packages/core/src/event.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,6 @@ export const layerWith = (options?: LayerOptions) =>
569569
)
570570

571571
export const layer = layerWith()
572-
export const node = LayerNode.make(layer, [Database.node])
572+
export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] })
573573

574574
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))

packages/core/src/fs-util.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ export namespace FSUtil {
201201
)
202202

203203
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
204-
export const node = LayerNode.make(layer, [filesystem])
204+
export const node = LayerNode.make({ service: Service, layer: layer, deps: [filesystem] })
205205

206206
// Pure helpers that don't need Effect (path manipulation, sync operations)
207207
export function mimeType(p: string): string {

packages/core/src/git.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -944,7 +944,7 @@ export const layer = Layer.effect(
944944
)
945945

946946
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer))
947-
export const node = LayerNode.make(layer, [FSUtil.node, AppProcess.node])
947+
export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, AppProcess.node] })
948948

949949
interface Result {
950950
readonly exitCode: number

packages/core/src/global.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export const layer = Layer.effect(
7777
)
7878

7979
export const defaultLayer = layer
80-
export const node = LayerNode.make(layer, [])
80+
export const node = LayerNode.make({ service: Service, layer: layer, deps: [] })
8181

8282
export const layerWith = (input: Partial<Interface>) =>
8383
Layer.effect(

packages/core/src/models-dev.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,6 @@ export const defaultLayer = layer.pipe(
244244
Layer.provide(FSUtil.defaultLayer),
245245
Layer.provide(EventV2.defaultLayer),
246246
)
247-
export const node = LayerNode.make(layer, [FSUtil.node, EventV2.node, httpClient])
247+
export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] })
248248

249249
export * as ModelsDev from "./models-dev"

0 commit comments

Comments
 (0)