mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 18:26:09 +00:00
refactor(core): support tiered layer nodes (#33937)
This commit is contained in:
@@ -503,6 +503,6 @@ export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSyste
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
|
||||
export const node = LayerNode.make(layer, [filesystem, path])
|
||||
export const node = LayerNode.make({ service: ChildProcessSpawner, layer, deps: [filesystem, path] })
|
||||
|
||||
export * as CrossSpawnSpawner from "./cross-spawn-spawner"
|
||||
|
||||
@@ -60,4 +60,4 @@ export const defaultLayer = Layer.unwrap(
|
||||
}),
|
||||
).pipe(Layer.provide(Global.defaultLayer))
|
||||
|
||||
export const node = LayerNode.make(layerFromPath(path()), [])
|
||||
export const node = LayerNode.make({ service: Service, layer: layerFromPath(path()), deps: [] })
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
|
||||
import { FileSystem, Path } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { LayerNode } from "./layer-node"
|
||||
|
||||
export const filesystem = LayerNode.make(NodeFileSystem.layer, [])
|
||||
export const path = LayerNode.make(NodePath.layer, [])
|
||||
export const httpClient = LayerNode.make(FetchHttpClient.layer, [])
|
||||
export const requestExecutor = LayerNode.make(RequestExecutor.layer, [httpClient])
|
||||
export const llmClient = LayerNode.make(LLMClient.layer, [requestExecutor])
|
||||
export const filesystem = LayerNode.make({ service: FileSystem.FileSystem, layer: NodeFileSystem.layer, deps: [] })
|
||||
export const path = LayerNode.make({ service: Path.Path, layer: NodePath.layer, deps: [] })
|
||||
export const httpClient = LayerNode.make({ service: HttpClient.HttpClient, layer: FetchHttpClient.layer, deps: [] })
|
||||
export const requestExecutor = LayerNode.make({
|
||||
service: RequestExecutor.Service,
|
||||
layer: RequestExecutor.layer,
|
||||
deps: [httpClient],
|
||||
})
|
||||
export const llmClient = LayerNode.make({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] })
|
||||
|
||||
export * as LayerNodePlatform from "./layer-node-platform"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Layer } from "effect"
|
||||
import { Brand, Context, Layer } from "effect"
|
||||
|
||||
type RuntimeLayer = Layer.Layer<never, unknown, unknown>
|
||||
type AnyNode = Node<unknown, unknown>
|
||||
type NodeList = readonly [] | readonly [AnyNode, ...AnyNode[]]
|
||||
type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown> ? A : never
|
||||
type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E> ? E : never
|
||||
type AnyNode = Node<unknown, unknown, any>
|
||||
type NodeList<Item extends AnyNode = AnyNode> = readonly [] | readonly [Item, ...Item[]]
|
||||
type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown, any> ? A : never
|
||||
type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E, any> ? E : never
|
||||
type Missing<Required, Dependencies extends NodeList> = Exclude<Required, Output<Dependencies[number]>>
|
||||
type CheckDependencies<Implementation extends Layer.Any, Dependencies extends NodeList> = [
|
||||
Missing<Layer.Services<Implementation>, Dependencies>,
|
||||
@@ -14,89 +14,235 @@ type CheckDependencies<Implementation extends Layer.Any, Dependencies extends No
|
||||
declare const $OutputType: unique symbol
|
||||
declare const $ErrorType: unique symbol
|
||||
|
||||
export type Node<A, E = never> = {
|
||||
export type Tier<Name extends string = string> = Name & Brand.Brand<"LayerNode.Tier">
|
||||
|
||||
const makeTier = Brand.nominal<Tier>()
|
||||
|
||||
export type Node<A, E = never, T extends Tier | undefined = undefined> = {
|
||||
readonly kind: "layer" | "group"
|
||||
readonly name: string
|
||||
readonly service?: Context.Service.Any
|
||||
readonly implementation?: Layer.Any
|
||||
readonly dependencies: readonly AnyNode[]
|
||||
readonly tier?: T
|
||||
readonly [$OutputType]?: () => A
|
||||
readonly [$ErrorType]?: () => E
|
||||
}
|
||||
|
||||
export function make<const Implementation extends Layer.Any, const Items extends NodeList>(
|
||||
implementation: Implementation,
|
||||
dependencies: Items & CheckDependencies<Implementation, NoInfer<Items>>,
|
||||
): Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>> {
|
||||
return { kind: "layer", implementation: implementation as Layer.Any, dependencies }
|
||||
type NodeIdentity =
|
||||
| { readonly service: Context.Service.Any; readonly name?: never }
|
||||
| { readonly name: string; readonly service?: never }
|
||||
type DistributiveOmit<A, K extends PropertyKey> = A extends unknown ? Omit<A, K> : never
|
||||
|
||||
type NodeInput<
|
||||
Implementation extends Layer.Any,
|
||||
Items extends NodeList,
|
||||
T extends Tier | undefined = undefined,
|
||||
> = NodeIdentity & {
|
||||
readonly layer: Implementation
|
||||
readonly deps: Items & CheckDependencies<Implementation, NoInfer<Items>>
|
||||
readonly tier?: T
|
||||
}
|
||||
|
||||
export function make<
|
||||
const Implementation extends Layer.Any,
|
||||
const Items extends NodeList,
|
||||
const T extends Tier | undefined = undefined,
|
||||
>(
|
||||
input: NodeInput<Implementation, Items, T>,
|
||||
): Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, T> {
|
||||
return {
|
||||
kind: "layer",
|
||||
name: input.service !== undefined ? input.service.key : input.name,
|
||||
service: input.service,
|
||||
implementation: input.layer,
|
||||
dependencies: input.deps,
|
||||
tier: input.tier,
|
||||
}
|
||||
}
|
||||
|
||||
export function group<const Items extends NodeList>(
|
||||
dependencies: Items,
|
||||
): Node<Output<Items[number]>, Error<Items[number]>> {
|
||||
return { kind: "group", dependencies }
|
||||
return { kind: "group", name: "group", dependencies }
|
||||
}
|
||||
|
||||
export type Replacement<A = unknown> = {
|
||||
readonly source: Node<A, unknown>
|
||||
readonly replacement: Node<A, unknown>
|
||||
type AllowedTierNames<Names extends readonly string[], Name extends Names[number]> = Names extends readonly [
|
||||
infer Head extends string,
|
||||
...infer Tail extends readonly string[],
|
||||
]
|
||||
? Head extends Name
|
||||
? Head | Tail[number]
|
||||
: AllowedTierNames<Tail, Name>
|
||||
: never
|
||||
|
||||
type NodeInTiers<Names extends string> = Node<unknown, unknown, Tier<Names>>
|
||||
|
||||
export interface Tiers<Names extends readonly [string, ...string[]]> {
|
||||
readonly names: Names
|
||||
readonly values: { readonly [K in Names[number]]: Tier<K> }
|
||||
readonly make: <Name extends Names[number]>(
|
||||
name: Name,
|
||||
) => <
|
||||
const Implementation extends Layer.Any,
|
||||
const Items extends NodeList<NodeInTiers<AllowedTierNames<Names, Name>>>,
|
||||
>(
|
||||
input: DistributiveOmit<NodeInput<Implementation, Items, Tier<Name>>, "tier">,
|
||||
) => Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, Tier<Name>>
|
||||
}
|
||||
|
||||
export function tiers<const Names extends readonly [string, ...string[]]>(names: Names): Tiers<Names> {
|
||||
const values = Object.fromEntries(names.map((name) => [name, makeTier(name)])) as Tiers<Names>["values"]
|
||||
return {
|
||||
names,
|
||||
values,
|
||||
make: ((name: Names[number]) => (input: DistributiveOmit<NodeInput<Layer.Any, NodeList, Tier>, "tier">) =>
|
||||
make({ ...input, tier: values[name] })) as Tiers<Names>["make"],
|
||||
}
|
||||
}
|
||||
|
||||
const defaultTiers = tiers(["untiered"])
|
||||
const untiered = defaultTiers.values.untiered
|
||||
|
||||
export type Replacement = {
|
||||
readonly source: Layer.Any
|
||||
readonly replacement: Layer.Any
|
||||
}
|
||||
|
||||
type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<ReplacementError, SourceError>] extends [never]
|
||||
? unknown
|
||||
: { readonly "New replacement errors": Exclude<ReplacementError, SourceError> }
|
||||
|
||||
export function replaceWithNode<A, E, E2>(
|
||||
source: Node<A, E>,
|
||||
replacement: Node<NoInfer<A>, E2> & CheckReplacementErrors<E, NoInfer<E2>>,
|
||||
): Replacement<A> {
|
||||
export function replace<A, E, R, E2>(
|
||||
source: Layer.Layer<A, E, R>,
|
||||
replacement: Layer.Layer<NoInfer<A>, E2, never> & CheckReplacementErrors<E, NoInfer<E2>>,
|
||||
): Replacement {
|
||||
return { source, replacement }
|
||||
}
|
||||
|
||||
export function replace<A, E, E2>(
|
||||
source: Node<A, E>,
|
||||
replacement: Layer.Layer<NoInfer<A>, E2, never> & CheckReplacementErrors<E, NoInfer<E2>>,
|
||||
): Replacement<A> {
|
||||
return { source, replacement: make(replacement as Layer.Layer<A, E2>, []) }
|
||||
export function buildLayer<
|
||||
A,
|
||||
E,
|
||||
const Names extends readonly [string, ...string[]] = readonly ["untiered"],
|
||||
const Built extends Layer.Any = Layer.Layer<never, never, never>,
|
||||
>(
|
||||
node: Node<A, E, any>,
|
||||
options?: {
|
||||
readonly tiers?: Tiers<Names>
|
||||
readonly buildTier?: (tier: Names[number], layers: readonly Layer.Any[]) => Built
|
||||
readonly replacements?: readonly Replacement[]
|
||||
},
|
||||
): Layer.Layer<A | Layer.Success<Built>, E | Layer.Error<Built>, never> {
|
||||
const tiers = options?.tiers ?? (defaultTiers as unknown as Tiers<Names>)
|
||||
const replacementMap = new Map(options?.replacements?.map((item) => [item.source, item.replacement]))
|
||||
const plans = plan(node, tiers, replacementMap)
|
||||
const layers: RuntimeLayer[] = tiers.names.map((name) => {
|
||||
const tier = tiers.values[name as Names[number]]
|
||||
const layers = plans.get(tier) ?? []
|
||||
return (options?.buildTier?.(name, layers) ?? combine(layers)) as RuntimeLayer
|
||||
})
|
||||
if (layers.length === 0) return Layer.empty as never
|
||||
return layers.slice(1).reduce((result, layer) => result.pipe(Layer.provideMerge(layer)), layers[0]) as never
|
||||
}
|
||||
|
||||
export function buildLayer<A, E>(node: Node<A, E>, options?: { readonly replacements?: readonly Replacement[] }) {
|
||||
const replacements = new Map(options?.replacements?.map((item) => [item.source, item.replacement]))
|
||||
const cache = new Map<AnyNode, RuntimeLayer>()
|
||||
export function combine(layers: readonly Layer.Any[]): RuntimeLayer {
|
||||
return layers.reduce<RuntimeLayer>(
|
||||
(result, layer) => (layer as RuntimeLayer).pipe(Layer.provideMerge(result)),
|
||||
Layer.empty as RuntimeLayer,
|
||||
)
|
||||
}
|
||||
|
||||
function plan(
|
||||
root: AnyNode,
|
||||
tiers: Tiers<readonly [string, ...string[]]>,
|
||||
replacements: ReadonlyMap<Layer.Any, Layer.Any>,
|
||||
) {
|
||||
const indexes = new Map(tiers.names.map((name, index) => [tiers.values[name], index]))
|
||||
const plans = new Map<Tier, Layer.Any[]>()
|
||||
const activeImplementations = new Map<Tier, Map<string, AnyNode>>()
|
||||
const serviceTiers = new Map<string, Tier>()
|
||||
const visiting = new Set<AnyNode>()
|
||||
const stack: AnyNode[] = []
|
||||
const ids = new Map<AnyNode, number>()
|
||||
const boundaryVisited = new Map<AnyNode, Set<Tier>>()
|
||||
const boundaryServices = new Map<Tier, Map<string, AnyNode>>()
|
||||
|
||||
const validateBoundary = (node: AnyNode, origin: Tier) => {
|
||||
const checked = boundaryVisited.get(node) ?? new Set<Tier>()
|
||||
boundaryVisited.set(node, checked)
|
||||
if (checked.has(origin)) return false
|
||||
checked.add(origin)
|
||||
const services = boundaryServices.get(origin) ?? new Map<string, AnyNode>()
|
||||
boundaryServices.set(origin, services)
|
||||
const key = node.name
|
||||
const existing = services.get(key)
|
||||
if (existing && existing !== node) {
|
||||
throw new Error(`Tier ${origin} has conflicting implementations for ${key}`)
|
||||
}
|
||||
services.set(key, node)
|
||||
return true
|
||||
}
|
||||
|
||||
const visit = (node: AnyNode, currentTier?: Tier, origins: readonly Tier[] = []) => {
|
||||
if (node.kind === "group") {
|
||||
node.dependencies.forEach((dependency) => visit(dependency, currentTier, origins))
|
||||
return
|
||||
}
|
||||
|
||||
const tier = node.tier ?? untiered
|
||||
if (!indexes.has(tier)) throw new Error(`Node ${node.name} is not in the tier configuration`)
|
||||
const key = node.name
|
||||
const serviceTier = serviceTiers.get(key)
|
||||
if (serviceTier && serviceTier !== tier) {
|
||||
throw new Error(`Service ${key} belongs to both tier ${serviceTier} and tier ${tier}`)
|
||||
}
|
||||
serviceTiers.set(key, tier)
|
||||
const nextOrigins = [...origins]
|
||||
if (currentTier) {
|
||||
const current = indexes.get(currentTier)!
|
||||
const required = indexes.get(tier)!
|
||||
if (required < current) {
|
||||
throw new Error(`Tier ${currentTier} cannot depend on lower tier ${tier}`)
|
||||
}
|
||||
if (required > current) nextOrigins.push(currentTier)
|
||||
}
|
||||
const unseenOrigins = nextOrigins.filter((origin) => validateBoundary(node, origin))
|
||||
|
||||
// A node may need to be emitted more than once because the final output is a
|
||||
// flat list of layers applied with Layer.provideMerge. If another node for
|
||||
// the same service was emitted afterward, this node is no longer the active
|
||||
// implementation for subsequent consumers. Re-emitting restores the intended
|
||||
// implementation ordering while Effect memoization avoids reacquiring the layer.
|
||||
const implementations = activeImplementations.get(tier) ?? new Map<string, AnyNode>()
|
||||
activeImplementations.set(tier, implementations)
|
||||
if (implementations.get(key) === node && unseenOrigins.length === 0) return
|
||||
|
||||
const visit = (input: AnyNode): RuntimeLayer => {
|
||||
const node = replacements.get(input) ?? input
|
||||
const cached = cache.get(node)
|
||||
if (cached) return cached
|
||||
if (visiting.has(node)) {
|
||||
const start = stack.indexOf(node)
|
||||
const cycle = [...stack.slice(start), node].map((item) => `${item.kind}#${ids.get(item)}`).join(" -> ")
|
||||
throw new Error(`Cycle detected in app graph: ${cycle}`)
|
||||
throw new Error(
|
||||
`Cycle detected in layer graph: ${[...stack.slice(start), node].map((item) => item.name).join(" -> ")}`,
|
||||
)
|
||||
}
|
||||
if (!ids.has(node)) ids.set(node, ids.size + 1)
|
||||
|
||||
visiting.add(node)
|
||||
stack.push(node)
|
||||
try {
|
||||
const dependencies = node.dependencies.map(visit)
|
||||
const nonEmpty = dependencies as [RuntimeLayer, ...RuntimeLayer[]]
|
||||
const result =
|
||||
node.kind === "group"
|
||||
? dependencies.length === 0
|
||||
? Layer.empty
|
||||
: Layer.mergeAll(...nonEmpty)
|
||||
: dependencies.length === 0
|
||||
? (node.implementation as RuntimeLayer)
|
||||
: Layer.provide(node.implementation as RuntimeLayer, nonEmpty)
|
||||
cache.set(node, result)
|
||||
return result
|
||||
node.dependencies.forEach((dependency) => visit(dependency, tier, unseenOrigins))
|
||||
const layers = plans.get(tier) ?? []
|
||||
plans.set(tier, layers)
|
||||
layers.push(replacements.get(node.implementation!) ?? node.implementation!)
|
||||
implementations.set(key, node)
|
||||
} finally {
|
||||
stack.pop()
|
||||
visiting.delete(node)
|
||||
}
|
||||
}
|
||||
|
||||
return visit(node) as unknown as Layer.Layer<A, E, never>
|
||||
visit(root)
|
||||
return plans
|
||||
}
|
||||
|
||||
function requireTier(node: AnyNode, indexes: ReadonlyMap<Tier, number>) {
|
||||
if (!node.tier || !indexes.has(node.tier)) throw new Error(`Node ${node.name} is not in the tier configuration`)
|
||||
}
|
||||
|
||||
export * as LayerNode from "./layer-node"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { LayerNode } from "./layer-node"
|
||||
|
||||
export const tiers = LayerNode.tiers(["location", "global"])
|
||||
|
||||
export type GlobalNode<A, E = never> = LayerNode.Node<A, E, (typeof tiers.values)["global"]>
|
||||
export type LocationNode<A, E = never> = LayerNode.Node<A, E, (typeof tiers.values)["location"]>
|
||||
|
||||
export const makeGlobalNode = tiers.make("global")
|
||||
export const makeLocationNode = tiers.make("location")
|
||||
|
||||
export * as ScopedNode from "./scoped-node"
|
||||
@@ -569,6 +569,6 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
)
|
||||
|
||||
export const layer = layerWith()
|
||||
export const node = LayerNode.make(layer, [Database.node])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] })
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||
|
||||
@@ -201,7 +201,7 @@ export namespace FSUtil {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
|
||||
export const node = LayerNode.make(layer, [filesystem])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [filesystem] })
|
||||
|
||||
// Pure helpers that don't need Effect (path manipulation, sync operations)
|
||||
export function mimeType(p: string): string {
|
||||
|
||||
@@ -944,7 +944,7 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer))
|
||||
export const node = LayerNode.make(layer, [FSUtil.node, AppProcess.node])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, AppProcess.node] })
|
||||
|
||||
interface Result {
|
||||
readonly exitCode: number
|
||||
|
||||
@@ -77,7 +77,7 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
export const node = LayerNode.make(layer, [])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [] })
|
||||
|
||||
export const layerWith = (input: Partial<Interface>) =>
|
||||
Layer.effect(
|
||||
|
||||
@@ -244,6 +244,6 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
)
|
||||
export const node = LayerNode.make(layer, [FSUtil.node, EventV2.node, httpClient])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] })
|
||||
|
||||
export * as ModelsDev from "./models-dev"
|
||||
|
||||
@@ -253,7 +253,11 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Global.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
)
|
||||
export const node = LayerNode.make(layer, [FSUtil.node, Global.node, filesystem, EffectFlock.node])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Global.node, filesystem, EffectFlock.node],
|
||||
})
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
|
||||
@@ -238,6 +238,6 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
|
||||
export const node = LayerNode.make(layer, [CrossSpawnSpawner.node])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [CrossSpawnSpawner.node] })
|
||||
|
||||
export * as AppProcess from "./process"
|
||||
|
||||
@@ -134,4 +134,8 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provideMerge(ProjectDirectories.defaultLayer),
|
||||
)
|
||||
export const node = LayerNode.make(layer, [FSUtil.node, Git.node, ProjectDirectories.node])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Git.node, ProjectDirectories.node],
|
||||
})
|
||||
|
||||
@@ -279,4 +279,8 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
export const node = LayerNode.make(layer, [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node],
|
||||
})
|
||||
|
||||
@@ -156,4 +156,4 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||
export const node = LayerNode.make(layer, [Database.node])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] })
|
||||
|
||||
@@ -54,4 +54,4 @@ export const make = (ttl: Duration.Input = DEFAULT_TTL) =>
|
||||
export const layer = Layer.effect(Service, make())
|
||||
|
||||
export const defaultLayer = layer
|
||||
export const node = LayerNode.make(layer, [])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [] })
|
||||
|
||||
@@ -279,4 +279,4 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Layer.merge(RipgrepBinary.defaultLayer, AppProcess.defaultLayer)))
|
||||
export const node = LayerNode.make(layer, [RipgrepBinary.node, AppProcess.node])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] })
|
||||
|
||||
@@ -130,5 +130,9 @@ export namespace RipgrepBinary {
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
)
|
||||
|
||||
export const node = LayerNode.make(layer, [FSUtil.node, httpClient, CrossSpawnSpawner.node])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, httpClient, CrossSpawnSpawner.node],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -456,4 +456,4 @@ export const layer = Layer.effectDiscard(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer))
|
||||
export const node = LayerNode.make(layer, [EventV2.node, Database.node])
|
||||
export const node = LayerNode.make({ name: "session-projector", layer, deps: [EventV2.node, Database.node] })
|
||||
|
||||
@@ -281,5 +281,5 @@ export namespace EffectFlock {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.layer))
|
||||
export const node = LayerNode.make(layer, [Global.node, FSUtil.node])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [Global.node, FSUtil.node] })
|
||||
}
|
||||
|
||||
@@ -458,6 +458,6 @@ export const layer: Layer.Layer<Service, never, AccountRepo.Service | HttpClient
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.defaultLayer), Layer.provide(FetchHttpClient.layer))
|
||||
|
||||
export const node = LayerNode.make(layer, [AccountRepo.node, httpClient])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [AccountRepo.node, httpClient] })
|
||||
|
||||
export * as Account from "./account"
|
||||
|
||||
@@ -168,6 +168,6 @@ export const layer = Layer.effect(
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||
|
||||
export const node = LayerNode.make(layer, [Database.node])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] })
|
||||
|
||||
export * as AccountRepo from "./repo"
|
||||
|
||||
@@ -447,15 +447,12 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
)
|
||||
|
||||
const locationServiceMapNode = LayerNode.make(LocationServiceMap.layer, [])
|
||||
const locationServiceMapNode = LayerNode.make({ service: Service, layer: LocationServiceMap.layer, deps: [] })
|
||||
|
||||
export const node = LayerNode.make(layer, [
|
||||
Config.node,
|
||||
Auth.node,
|
||||
Plugin.node,
|
||||
Skill.node,
|
||||
Provider.node,
|
||||
locationServiceMapNode,
|
||||
])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [Config.node, Auth.node, Plugin.node, Skill.node, Provider.node, locationServiceMapNode],
|
||||
})
|
||||
|
||||
export * as Agent from "./agent"
|
||||
|
||||
@@ -94,6 +94,6 @@ export const layer = Layer.effect(
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer))
|
||||
|
||||
export const node = LayerNode.make(layer, [FSUtil.node])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node] })
|
||||
|
||||
export * as Auth from "."
|
||||
|
||||
@@ -34,6 +34,6 @@ export const layer = Layer.effect(
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
export const node = LayerNode.make(layer, [])
|
||||
export const node = LayerNode.make({ service: CoreBackgroundJob.Service, layer, deps: [] })
|
||||
|
||||
export * as BackgroundJob from "./job"
|
||||
|
||||
@@ -170,6 +170,6 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
)
|
||||
|
||||
export const node = LayerNode.make(layer, [Config.node, MCP.node, Skill.node])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [Config.node, MCP.node, Skill.node] })
|
||||
|
||||
export * as Command from "."
|
||||
|
||||
@@ -681,6 +681,10 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
)
|
||||
|
||||
export const node = LayerNode.make(layer, [FSUtil.node, Auth.node, Account.node, Env.node, Npm.node, httpClient])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Auth.node, Account.node, Env.node, Npm.node, httpClient],
|
||||
})
|
||||
|
||||
export * as Config from "./config"
|
||||
|
||||
@@ -958,16 +958,20 @@ function route(url: string | URL, path: string) {
|
||||
return next
|
||||
}
|
||||
|
||||
export const node = LayerNode.make(layer, [
|
||||
Auth.node,
|
||||
Session.node,
|
||||
SessionPrompt.node,
|
||||
httpClient,
|
||||
EventV2Bridge.node,
|
||||
Vcs.node,
|
||||
RuntimeFlags.node,
|
||||
FSUtil.node,
|
||||
Database.node,
|
||||
])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [
|
||||
Auth.node,
|
||||
Session.node,
|
||||
SessionPrompt.node,
|
||||
httpClient,
|
||||
EventV2Bridge.node,
|
||||
Vcs.node,
|
||||
RuntimeFlags.node,
|
||||
FSUtil.node,
|
||||
Database.node,
|
||||
],
|
||||
})
|
||||
|
||||
export * as Workspace from "./workspace"
|
||||
|
||||
@@ -73,7 +73,7 @@ export const layer = (overrides: Partial<Info> = {}) =>
|
||||
|
||||
export const defaultLayer = Service.defaultLayer.pipe(Layer.orDie)
|
||||
|
||||
export const node = LayerNode.make(defaultLayer, [])
|
||||
export const node = LayerNode.make({ service: Service, layer: defaultLayer, deps: [] })
|
||||
|
||||
export * as RuntimeFlags from "./runtime-flags"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
|
||||
Vendored
+1
-1
@@ -38,6 +38,6 @@ export const layer = Layer.effect(
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
export const node = LayerNode.make(layer, [])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [] })
|
||||
|
||||
export * as Env from "."
|
||||
|
||||
@@ -68,6 +68,6 @@ export const layer = Layer.effect(
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer))
|
||||
|
||||
export const node = LayerNode.make(layer, [EventV2.node])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2.node] })
|
||||
|
||||
export * as EventV2Bridge from "./event-v2-bridge"
|
||||
|
||||
@@ -200,6 +200,10 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
)
|
||||
|
||||
export const node = LayerNode.make(layer, [Config.node, AppProcess.node, RuntimeFlags.node])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [Config.node, AppProcess.node, RuntimeFlags.node],
|
||||
})
|
||||
|
||||
export * as Format from "."
|
||||
|
||||
@@ -345,6 +345,6 @@ export const layer = Layer.effect(
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppProcess.defaultLayer))
|
||||
|
||||
export const node = LayerNode.make(layer, [AppProcess.node])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [AppProcess.node] })
|
||||
|
||||
export * as Git from "."
|
||||
|
||||
@@ -169,6 +169,6 @@ export const layer = Layer.effect(
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
||||
|
||||
export const node = LayerNode.make(layer, [Config.node])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [Config.node] })
|
||||
|
||||
export * as Image from "./image"
|
||||
|
||||
@@ -332,6 +332,6 @@ export const latest = (...args: Parameters<Interface["latest"]>) => runPromise((
|
||||
export const method = () => runPromise((s) => s.method())
|
||||
export const upgrade = (...args: Parameters<Interface["upgrade"]>) => runPromise((s) => s.upgrade(...args))
|
||||
|
||||
export const node = LayerNode.make(layer, [httpClient, AppProcess.node])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [httpClient, AppProcess.node] })
|
||||
|
||||
export * as Installation from "."
|
||||
|
||||
@@ -504,6 +504,10 @@ export const defaultLayer = layer.pipe(
|
||||
|
||||
export * as Diagnostic from "./diagnostic"
|
||||
|
||||
export const node = LayerNode.make(layer, [Config.node, RuntimeFlags.node, FSUtil.node, EventV2Bridge.node])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [Config.node, RuntimeFlags.node, FSUtil.node, EventV2Bridge.node],
|
||||
})
|
||||
|
||||
export * as LSP from "./lsp"
|
||||
|
||||
@@ -169,6 +169,6 @@ export const layer = Layer.effect(
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(EffectFlock.defaultLayer), Layer.provide(FSUtil.defaultLayer))
|
||||
|
||||
export const node = LayerNode.make(layer, [FSUtil.node, EffectFlock.node])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, EffectFlock.node] })
|
||||
|
||||
export * as McpAuth from "./auth"
|
||||
|
||||
@@ -1005,6 +1005,10 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
)
|
||||
|
||||
export const node = LayerNode.make(layer, [CrossSpawnSpawner.node, McpAuth.node, EventV2Bridge.node, Config.node])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [CrossSpawnSpawner.node, McpAuth.node, EventV2Bridge.node, Config.node],
|
||||
})
|
||||
|
||||
export * as MCP from "."
|
||||
|
||||
@@ -215,6 +215,6 @@ export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set<st
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer))
|
||||
|
||||
export const node = LayerNode.make(layer, [EventV2Bridge.node])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node] })
|
||||
|
||||
export * as Permission from "."
|
||||
|
||||
@@ -311,6 +311,10 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
)
|
||||
|
||||
export const node = LayerNode.make(layer, [EventV2Bridge.node, Config.node, RuntimeFlags.node])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [EventV2Bridge.node, Config.node, RuntimeFlags.node],
|
||||
})
|
||||
|
||||
export * as Plugin from "."
|
||||
|
||||
@@ -62,15 +62,10 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
||||
]),
|
||||
)
|
||||
|
||||
export const node = LayerNode.make(layer, [
|
||||
Config.node,
|
||||
Format.node,
|
||||
LSP.node,
|
||||
Plugin.node,
|
||||
Project.node,
|
||||
ShareNext.node,
|
||||
Snapshot.node,
|
||||
Vcs.node,
|
||||
])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [Config.node, Format.node, LSP.node, Plugin.node, Project.node, ShareNext.node, Snapshot.node, Vcs.node],
|
||||
})
|
||||
|
||||
export * as InstanceBootstrap from "./bootstrap"
|
||||
|
||||
@@ -204,6 +204,10 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Project.defaultLayer))
|
||||
|
||||
export const node = LayerNode.make(layer, [Project.node, InstanceBootstrapGraph.node])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [Project.node, InstanceBootstrapGraph.node],
|
||||
})
|
||||
|
||||
export * as InstanceStore from "./instance-store"
|
||||
|
||||
@@ -476,15 +476,19 @@ export const defaultLayer = layer.pipe(
|
||||
|
||||
export const use = serviceUse(Service)
|
||||
|
||||
export const node = LayerNode.make(layer, [
|
||||
FSUtil.node,
|
||||
AppProcess.node,
|
||||
CrossSpawnSpawner.node,
|
||||
ProjectV2.node,
|
||||
ProjectDirectories.node,
|
||||
EventV2Bridge.node,
|
||||
RuntimeFlags.node,
|
||||
Database.node,
|
||||
])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [
|
||||
FSUtil.node,
|
||||
AppProcess.node,
|
||||
CrossSpawnSpawner.node,
|
||||
ProjectV2.node,
|
||||
ProjectDirectories.node,
|
||||
EventV2Bridge.node,
|
||||
RuntimeFlags.node,
|
||||
Database.node,
|
||||
],
|
||||
})
|
||||
|
||||
export * as Project from "./project"
|
||||
|
||||
@@ -420,6 +420,6 @@ export const layer: Layer.Layer<Service, never, Git.Service | EventV2Bridge.Serv
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Git.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer))
|
||||
|
||||
export const node = LayerNode.make(layer, [Git.node, EventV2Bridge.node])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [Git.node, EventV2Bridge.node] })
|
||||
|
||||
export * as Vcs from "./vcs"
|
||||
|
||||
@@ -228,6 +228,6 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
layer.pipe(Layer.provide(Auth.defaultLayer), Layer.provide(Plugin.defaultLayer)),
|
||||
)
|
||||
|
||||
export const node = LayerNode.make(layer, [Auth.node, Plugin.node])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [Auth.node, Plugin.node] })
|
||||
|
||||
export * as ProviderAuth from "./auth"
|
||||
|
||||
@@ -1979,14 +1979,10 @@ export function parseModel(model: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export const node = LayerNode.make(layer, [
|
||||
FSUtil.node,
|
||||
Config.node,
|
||||
Auth.node,
|
||||
Env.node,
|
||||
Plugin.node,
|
||||
ModelsDev.node,
|
||||
RuntimeFlags.node,
|
||||
])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Config.node, Auth.node, Env.node, Plugin.node, ModelsDev.node, RuntimeFlags.node],
|
||||
})
|
||||
|
||||
export * as Provider from "./provider"
|
||||
|
||||
@@ -158,6 +158,6 @@ export const layer = Layer.effect(
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer))
|
||||
|
||||
export const node = LayerNode.make(layer, [EventV2Bridge.node])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node] })
|
||||
|
||||
export * as Question from "."
|
||||
|
||||
@@ -599,15 +599,19 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
),
|
||||
)
|
||||
|
||||
export const node = LayerNode.make(layer, [
|
||||
Config.node,
|
||||
Session.node,
|
||||
Agent.node,
|
||||
Plugin.node,
|
||||
SessionProcessor.node,
|
||||
Provider.node,
|
||||
EventV2Bridge.node,
|
||||
RuntimeFlags.node,
|
||||
])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [
|
||||
Config.node,
|
||||
Session.node,
|
||||
Agent.node,
|
||||
Plugin.node,
|
||||
SessionProcessor.node,
|
||||
Provider.node,
|
||||
EventV2Bridge.node,
|
||||
RuntimeFlags.node,
|
||||
],
|
||||
})
|
||||
|
||||
export * as SessionCompaction from "./compaction"
|
||||
|
||||
@@ -236,6 +236,10 @@ export function loaded(messages: SessionV1.WithParts[]) {
|
||||
return extract(messages)
|
||||
}
|
||||
|
||||
export const node = LayerNode.make(layer, [Config.node, FSUtil.node, Global.node, RuntimeFlags.node, httpClient])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [Config.node, FSUtil.node, Global.node, RuntimeFlags.node, httpClient],
|
||||
})
|
||||
|
||||
export * as Instruction from "./instruction"
|
||||
|
||||
@@ -401,15 +401,19 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
|
||||
export const hasToolCalls = LLMRequestPrep.hasToolCalls
|
||||
|
||||
export const node = LayerNode.make(layer, [
|
||||
Auth.node,
|
||||
Config.node,
|
||||
Provider.node,
|
||||
Plugin.node,
|
||||
Permission.node,
|
||||
EventV2Bridge.node,
|
||||
llmClient,
|
||||
RuntimeFlags.node,
|
||||
])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [
|
||||
Auth.node,
|
||||
Config.node,
|
||||
Provider.node,
|
||||
Plugin.node,
|
||||
Permission.node,
|
||||
EventV2Bridge.node,
|
||||
llmClient,
|
||||
RuntimeFlags.node,
|
||||
],
|
||||
})
|
||||
|
||||
export * as LLM from "./llm"
|
||||
|
||||
@@ -1065,20 +1065,24 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
),
|
||||
)
|
||||
|
||||
export const node = LayerNode.make(layer, [
|
||||
Session.node,
|
||||
Config.node,
|
||||
Snapshot.node,
|
||||
Agent.node,
|
||||
LLM.node,
|
||||
Permission.node,
|
||||
Plugin.node,
|
||||
SessionSummary.node,
|
||||
SessionStatus.node,
|
||||
Image.node,
|
||||
EventV2Bridge.node,
|
||||
RuntimeFlags.node,
|
||||
Database.node,
|
||||
])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [
|
||||
Session.node,
|
||||
Config.node,
|
||||
Snapshot.node,
|
||||
Agent.node,
|
||||
LLM.node,
|
||||
Permission.node,
|
||||
Plugin.node,
|
||||
SessionSummary.node,
|
||||
SessionStatus.node,
|
||||
Image.node,
|
||||
EventV2Bridge.node,
|
||||
RuntimeFlags.node,
|
||||
Database.node,
|
||||
],
|
||||
})
|
||||
|
||||
export * as SessionProcessor from "./processor"
|
||||
|
||||
@@ -1731,33 +1731,37 @@ const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
|
||||
const placeholderRegex = /\$(\d+)/g
|
||||
const quoteTrimRegex = /^["']|["']$/g
|
||||
|
||||
export const node = LayerNode.make(layer, [
|
||||
SessionStatus.node,
|
||||
Session.node,
|
||||
Agent.node,
|
||||
Provider.node,
|
||||
SessionProcessor.node,
|
||||
SessionCompaction.node,
|
||||
Plugin.node,
|
||||
Command.node,
|
||||
Config.node,
|
||||
Permission.node,
|
||||
FSUtil.node,
|
||||
MCP.node,
|
||||
LSP.node,
|
||||
ToolRegistry.node,
|
||||
Truncate.node,
|
||||
Image.node,
|
||||
CrossSpawnSpawner.node,
|
||||
Instruction.node,
|
||||
SessionRunState.node,
|
||||
SessionRevert.node,
|
||||
SessionSummary.node,
|
||||
SystemPrompt.node,
|
||||
LLM.node,
|
||||
EventV2Bridge.node,
|
||||
RuntimeFlags.node,
|
||||
Database.node,
|
||||
])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [
|
||||
SessionStatus.node,
|
||||
Session.node,
|
||||
Agent.node,
|
||||
Provider.node,
|
||||
SessionProcessor.node,
|
||||
SessionCompaction.node,
|
||||
Plugin.node,
|
||||
Command.node,
|
||||
Config.node,
|
||||
Permission.node,
|
||||
FSUtil.node,
|
||||
MCP.node,
|
||||
LSP.node,
|
||||
ToolRegistry.node,
|
||||
Truncate.node,
|
||||
Image.node,
|
||||
CrossSpawnSpawner.node,
|
||||
Instruction.node,
|
||||
SessionRunState.node,
|
||||
SessionRevert.node,
|
||||
SessionSummary.node,
|
||||
SystemPrompt.node,
|
||||
LLM.node,
|
||||
EventV2Bridge.node,
|
||||
RuntimeFlags.node,
|
||||
Database.node,
|
||||
],
|
||||
})
|
||||
|
||||
export * as SessionPrompt from "./prompt"
|
||||
|
||||
@@ -148,13 +148,10 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
),
|
||||
)
|
||||
|
||||
export const node = LayerNode.make(layer, [
|
||||
Session.node,
|
||||
Snapshot.node,
|
||||
Storage.node,
|
||||
EventV2Bridge.node,
|
||||
SessionSummary.node,
|
||||
SessionRunState.node,
|
||||
])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [Session.node, Snapshot.node, Storage.node, EventV2Bridge.node, SessionSummary.node, SessionRunState.node],
|
||||
})
|
||||
|
||||
export * as SessionRevert from "./revert"
|
||||
|
||||
@@ -151,6 +151,6 @@ function busyError(sessionID: SessionID) {
|
||||
return new Session.BusyError({ sessionID })
|
||||
}
|
||||
|
||||
export const node = LayerNode.make(layer, [BackgroundJob.node, SessionStatus.node])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [BackgroundJob.node, SessionStatus.node] })
|
||||
|
||||
export * as SessionRunState from "./run-state"
|
||||
|
||||
@@ -1070,6 +1070,10 @@ export function* listGlobal(input?: {
|
||||
}
|
||||
}
|
||||
|
||||
export const node = LayerNode.make(layer, [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node],
|
||||
})
|
||||
|
||||
export * as Session from "./session"
|
||||
|
||||
@@ -53,6 +53,6 @@ export const layer = Layer.effect(
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer))
|
||||
|
||||
export const node = LayerNode.make(layer, [EventV2Bridge.node])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node] })
|
||||
|
||||
export * as SessionStatus from "./status"
|
||||
|
||||
@@ -160,6 +160,10 @@ export const DiffInput = Schema.Struct({
|
||||
})
|
||||
export type DiffInput = Schema.Schema.Type<typeof DiffInput>
|
||||
|
||||
export const node = LayerNode.make(layer, [Session.node, Snapshot.node, EventV2Bridge.node, Config.node])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [Session.node, Snapshot.node, EventV2Bridge.node, Config.node],
|
||||
})
|
||||
|
||||
export * as SessionSummary from "./summary"
|
||||
|
||||
@@ -134,8 +134,12 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
)
|
||||
|
||||
const locationServiceMapNode = LayerNode.make(LocationServiceMap.layer, [])
|
||||
const locationServiceMapNode = LayerNode.make({ service: Service, layer: LocationServiceMap.layer, deps: [] })
|
||||
|
||||
export const node = LayerNode.make(layer, [Skill.node, MCP.node, locationServiceMapNode])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [Skill.node, MCP.node, locationServiceMapNode],
|
||||
})
|
||||
|
||||
export * as SystemPrompt from "./system"
|
||||
|
||||
@@ -71,6 +71,6 @@ export const layer = Layer.effect(
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(Database.defaultLayer))
|
||||
|
||||
export const node = LayerNode.make(layer, [EventV2Bridge.node, Database.node])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node, Database.node] })
|
||||
|
||||
export * as Todo from "./todo"
|
||||
|
||||
@@ -56,6 +56,10 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
)
|
||||
|
||||
export const node = LayerNode.make(layer, [Config.node, Session.node, ShareNext.node, RuntimeFlags.node])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [Config.node, Session.node, ShareNext.node, RuntimeFlags.node],
|
||||
})
|
||||
|
||||
export * as SessionShare from "./session"
|
||||
|
||||
@@ -372,14 +372,10 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Session.defaultLayer),
|
||||
)
|
||||
|
||||
export const node = LayerNode.make(layer, [
|
||||
Account.node,
|
||||
EventV2Bridge.node,
|
||||
Config.node,
|
||||
Database.node,
|
||||
httpClient,
|
||||
Provider.node,
|
||||
Session.node,
|
||||
])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [Account.node, EventV2Bridge.node, Config.node, Database.node, httpClient, Provider.node, Session.node],
|
||||
})
|
||||
|
||||
export * as ShareNext from "./share-next"
|
||||
|
||||
@@ -104,6 +104,6 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
|
||||
export const node = LayerNode.make(layer, [FSUtil.node, path, httpClient])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, path, httpClient] })
|
||||
|
||||
export * as Discovery from "./discovery"
|
||||
|
||||
@@ -354,13 +354,10 @@ export function fmt(list: Info[], opts: { verbose: boolean }) {
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
export const node = LayerNode.make(layer, [
|
||||
Discovery.node,
|
||||
Config.node,
|
||||
EventV2Bridge.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
RuntimeFlags.node,
|
||||
])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [Discovery.node, Config.node, EventV2Bridge.node, FSUtil.node, Global.node, RuntimeFlags.node],
|
||||
})
|
||||
|
||||
export * as Skill from "."
|
||||
|
||||
@@ -804,6 +804,10 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
)
|
||||
|
||||
export const node = LayerNode.make(layer, [FSUtil.node, AppProcess.node, Config.node])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, AppProcess.node, Config.node],
|
||||
})
|
||||
|
||||
export * as Snapshot from "."
|
||||
|
||||
@@ -324,6 +324,6 @@ export const layer = Layer.effect(
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer))
|
||||
|
||||
export const node = LayerNode.make(layer, [FSUtil.node, Git.node])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, Git.node] })
|
||||
|
||||
export * as Storage from "./storage"
|
||||
|
||||
@@ -415,26 +415,30 @@ function isJsonSchemaObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export const node = LayerNode.make(layer.pipe(Layer.provide(Ripgrep.defaultLayer)), [
|
||||
Config.node,
|
||||
Plugin.node,
|
||||
Question.node,
|
||||
Todo.node,
|
||||
Agent.node,
|
||||
Skill.node,
|
||||
Session.node,
|
||||
BackgroundJob.node,
|
||||
Provider.node,
|
||||
LSP.node,
|
||||
Instruction.node,
|
||||
FSUtil.node,
|
||||
EventV2Bridge.node,
|
||||
httpClient,
|
||||
CrossSpawnSpawner.node,
|
||||
Format.node,
|
||||
Truncate.node,
|
||||
RuntimeFlags.node,
|
||||
Database.node,
|
||||
])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer.pipe(Layer.provide(Ripgrep.defaultLayer)),
|
||||
deps: [
|
||||
Config.node,
|
||||
Plugin.node,
|
||||
Question.node,
|
||||
Todo.node,
|
||||
Agent.node,
|
||||
Skill.node,
|
||||
Session.node,
|
||||
BackgroundJob.node,
|
||||
Provider.node,
|
||||
LSP.node,
|
||||
Instruction.node,
|
||||
FSUtil.node,
|
||||
EventV2Bridge.node,
|
||||
httpClient,
|
||||
CrossSpawnSpawner.node,
|
||||
Format.node,
|
||||
Truncate.node,
|
||||
RuntimeFlags.node,
|
||||
Database.node,
|
||||
],
|
||||
})
|
||||
|
||||
export * as ToolRegistry from "./registry"
|
||||
|
||||
@@ -153,6 +153,6 @@ export const layer = Layer.effect(
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(NodePath.layer))
|
||||
|
||||
export const node = LayerNode.make(layer, [FSUtil.node])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node] })
|
||||
|
||||
export * as Truncate from "./truncate"
|
||||
|
||||
@@ -627,14 +627,10 @@ export const appLayer = layer.pipe(
|
||||
|
||||
export const defaultLayer = appLayer.pipe(Layer.provide(InstanceLayer.layer))
|
||||
|
||||
export const node = LayerNode.make(layer, [
|
||||
FSUtil.node,
|
||||
path,
|
||||
AppProcess.node,
|
||||
Git.node,
|
||||
Project.node,
|
||||
InstanceStore.node,
|
||||
Database.node,
|
||||
])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, path, AppProcess.node, Git.node, Project.node, InstanceStore.node, Database.node],
|
||||
})
|
||||
|
||||
export * as Worktree from "."
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import { test } from "bun:test"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
|
||||
class A extends Context.Service<A, { readonly value: "a" }>()("test/A") {}
|
||||
class B extends Context.Service<B, { readonly value: "b" }>()("test/B") {}
|
||||
class C extends Context.Service<C, { readonly value: "c" }>()("test/C") {}
|
||||
class LayerError {
|
||||
readonly _tag = "LayerError"
|
||||
}
|
||||
class NotFoundError {
|
||||
readonly _tag = "NotFoundError"
|
||||
}
|
||||
class DiskError {
|
||||
readonly _tag = "DiskError"
|
||||
}
|
||||
class NetworkError {
|
||||
readonly _tag = "NetworkError"
|
||||
}
|
||||
|
||||
const aImplementation = Layer.succeed(A, A.of({ value: "a" }))
|
||||
const bImplementation = Layer.effect(
|
||||
B,
|
||||
Effect.gen(function* () {
|
||||
yield* A
|
||||
return B.of({ value: "b" })
|
||||
}),
|
||||
)
|
||||
const cImplementation = Layer.effect(
|
||||
C,
|
||||
Effect.gen(function* () {
|
||||
yield* A
|
||||
yield* B
|
||||
return C.of({ value: "c" })
|
||||
}),
|
||||
)
|
||||
const failingAImplementation = Layer.effect(A, Effect.fail(new LayerError()))
|
||||
const notFoundAImplementation = Layer.effect(A, Effect.fail(new NotFoundError()))
|
||||
const diskAImplementation = Layer.effect(A, Effect.fail(new DiskError()))
|
||||
const networkAImplementation = Layer.effect(A, Effect.fail(new NetworkError()))
|
||||
const notFoundOrDiskAImplementation = Layer.effect(A, Effect.fail(new NotFoundError() as NotFoundError | DiskError))
|
||||
|
||||
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false
|
||||
type Assert<T extends true> = T
|
||||
|
||||
type AProvides = Assert<Equal<Layer.Success<typeof aImplementation>, A>>
|
||||
type ARequires = Assert<Equal<Layer.Services<typeof aImplementation>, never>>
|
||||
type BProvides = Assert<Equal<Layer.Success<typeof bImplementation>, B>>
|
||||
type BRequires = Assert<Equal<Layer.Services<typeof bImplementation>, A>>
|
||||
type CRequires = Assert<Equal<Layer.Services<typeof cImplementation>, A | B>>
|
||||
void (0 as unknown as AProvides)
|
||||
void (0 as unknown as ARequires)
|
||||
void (0 as unknown as BProvides)
|
||||
void (0 as unknown as BRequires)
|
||||
void (0 as unknown as CRequires)
|
||||
|
||||
const a = LayerNode.make(aImplementation, [])
|
||||
const b = LayerNode.make(bImplementation, [a])
|
||||
const c = LayerNode.make(cImplementation, [a, b])
|
||||
const failingA = LayerNode.make(failingAImplementation, [])
|
||||
const bWithFailingA = LayerNode.make(bImplementation, [failingA])
|
||||
const notFoundA = LayerNode.make(notFoundAImplementation, [])
|
||||
const diskA = LayerNode.make(diskAImplementation, [])
|
||||
const networkA = LayerNode.make(networkAImplementation, [])
|
||||
const notFoundOrDiskA = LayerNode.make(notFoundOrDiskAImplementation, [])
|
||||
|
||||
// @ts-expect-error B requires A
|
||||
LayerNode.make(bImplementation, [])
|
||||
|
||||
// @ts-expect-error C requires both A and B
|
||||
LayerNode.make(cImplementation, [a])
|
||||
|
||||
type ANodeProvides = Assert<Equal<typeof a, LayerNode.Node<A, never>>>
|
||||
type BNodeProvides = Assert<Equal<typeof b, LayerNode.Node<B, never>>>
|
||||
type CNodeProvides = Assert<Equal<typeof c, LayerNode.Node<C, never>>>
|
||||
type FailingANodeError = Assert<Equal<typeof failingA, LayerNode.Node<A, LayerError>>>
|
||||
type DependentNodeError = Assert<Equal<typeof bWithFailingA, LayerNode.Node<B, LayerError>>>
|
||||
void (0 as unknown as ANodeProvides)
|
||||
void (0 as unknown as BNodeProvides)
|
||||
void (0 as unknown as CNodeProvides)
|
||||
void (0 as unknown as FailingANodeError)
|
||||
void (0 as unknown as DependentNodeError)
|
||||
|
||||
const closed = LayerNode.buildLayer(c)
|
||||
const closedWithError = LayerNode.buildLayer(bWithFailingA)
|
||||
type ClosedProvides = Assert<Equal<Layer.Success<typeof closed>, C>>
|
||||
type ClosedRequires = Assert<Equal<Layer.Services<typeof closed>, never>>
|
||||
type ClosedError = Assert<Equal<Layer.Error<typeof closedWithError>, LayerError>>
|
||||
void (0 as unknown as ClosedProvides)
|
||||
void (0 as unknown as ClosedRequires)
|
||||
void (0 as unknown as ClosedError)
|
||||
|
||||
const replacement = LayerNode.make(Layer.succeed(A, A.of({ value: "a" })), [])
|
||||
LayerNode.replace(a, Layer.succeed(A, A.of({ value: "a" })))
|
||||
LayerNode.replace(notFoundOrDiskA, notFoundAImplementation)
|
||||
LayerNode.replace(notFoundOrDiskA, diskAImplementation)
|
||||
LayerNode.replaceWithNode(a, replacement)
|
||||
|
||||
// @ts-expect-error An override for A must still provide A
|
||||
LayerNode.replaceWithNode(a, b)
|
||||
|
||||
// @ts-expect-error A replacement cannot introduce NetworkError
|
||||
LayerNode.replace(notFoundOrDiskA, networkAImplementation)
|
||||
|
||||
// @ts-expect-error A replacement layer must not have unresolved dependencies
|
||||
LayerNode.replace(b, bImplementation)
|
||||
|
||||
test("type exploration compiles", () => {})
|
||||
@@ -1,204 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Cause, Context, Effect, Exit, Layer } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
|
||||
const { buildLayer: build, group, replace, replaceWithNode } = LayerNode
|
||||
const node = LayerNode.make
|
||||
|
||||
class Value extends Context.Service<Value, { readonly value: string }>()("test/Value") {}
|
||||
class Greeting extends Context.Service<Greeting, { readonly text: string }>()("test/Greeting") {}
|
||||
|
||||
const value = LayerNode.make(Layer.succeed(Value, Value.of({ value: "production" })), [])
|
||||
const greetingImplementation = Layer.effect(
|
||||
Greeting,
|
||||
Effect.gen(function* () {
|
||||
return Greeting.of({ text: `hello ${(yield* Value).value}` })
|
||||
}),
|
||||
)
|
||||
const greeting = LayerNode.make(greetingImplementation, [value])
|
||||
|
||||
// @ts-expect-error Greeting requires Value
|
||||
LayerNode.make(greetingImplementation, [])
|
||||
|
||||
describe("app graph", () => {
|
||||
test("creates any selected dependency layer", async () => {
|
||||
const result = Effect.gen(function* () {
|
||||
return (yield* Greeting).text
|
||||
}).pipe(Effect.provide(build(greeting)))
|
||||
|
||||
expect(await Effect.runPromise(result)).toBe("hello production")
|
||||
})
|
||||
|
||||
test("applies overrides before dependency materialization", async () => {
|
||||
const replacement = Layer.succeed(Value, Value.of({ value: "simulation" }))
|
||||
const graph = build(greeting, { replacements: [replace(value, replacement)] })
|
||||
const result = Effect.gen(function* () {
|
||||
return (yield* Greeting).text
|
||||
}).pipe(Effect.provide(graph))
|
||||
|
||||
expect(await Effect.runPromise(result)).toBe("hello simulation")
|
||||
})
|
||||
|
||||
test("acquires a shared dependency once", async () => {
|
||||
class Shared extends Context.Service<Shared, { readonly value: string }>()("test/Shared") {}
|
||||
class Left extends Context.Service<Left, { readonly value: string }>()("test/Left") {}
|
||||
class Right extends Context.Service<Right, { readonly value: string }>()("test/Right") {}
|
||||
let acquisitions = 0
|
||||
const shared = node(
|
||||
Layer.effect(
|
||||
Shared,
|
||||
Effect.sync(() => {
|
||||
acquisitions++
|
||||
return Shared.of({ value: "shared" })
|
||||
}),
|
||||
),
|
||||
[],
|
||||
)
|
||||
const left = node(
|
||||
Layer.effect(
|
||||
Left,
|
||||
Effect.gen(function* () {
|
||||
return Left.of({ value: `${(yield* Shared).value}-left` })
|
||||
}),
|
||||
),
|
||||
[shared],
|
||||
)
|
||||
const right = node(
|
||||
Layer.effect(
|
||||
Right,
|
||||
Effect.gen(function* () {
|
||||
return Right.of({ value: `${(yield* Shared).value}-right` })
|
||||
}),
|
||||
),
|
||||
[shared],
|
||||
)
|
||||
|
||||
const result = Effect.gen(function* () {
|
||||
return [(yield* Left).value, (yield* Right).value]
|
||||
}).pipe(Effect.provide(build(group([left, right]))))
|
||||
|
||||
expect(await Effect.runPromise(result)).toEqual(["shared-left", "shared-right"])
|
||||
expect(acquisitions).toBe(1)
|
||||
})
|
||||
|
||||
test("applies a replacement to every transitive consumer", async () => {
|
||||
class Left extends Context.Service<Left, { readonly value: string }>()("test/ReplacementLeft") {}
|
||||
class Right extends Context.Service<Right, { readonly value: string }>()("test/ReplacementRight") {}
|
||||
const left = node(
|
||||
Layer.effect(
|
||||
Left,
|
||||
Effect.gen(function* () {
|
||||
return Left.of({ value: (yield* Value).value })
|
||||
}),
|
||||
),
|
||||
[value],
|
||||
)
|
||||
const right = node(
|
||||
Layer.effect(
|
||||
Right,
|
||||
Effect.gen(function* () {
|
||||
return Right.of({ value: (yield* Value).value })
|
||||
}),
|
||||
),
|
||||
[value],
|
||||
)
|
||||
const replacement = Layer.succeed(Value, Value.of({ value: "simulation" }))
|
||||
const graph = build(group([left, right]), { replacements: [replace(value, replacement)] })
|
||||
|
||||
const result = Effect.gen(function* () {
|
||||
return [(yield* Left).value, (yield* Right).value]
|
||||
}).pipe(Effect.provide(graph))
|
||||
|
||||
expect(await Effect.runPromise(result)).toEqual(["simulation", "simulation"])
|
||||
})
|
||||
|
||||
test("propagates layer acquisition errors", async () => {
|
||||
class AcquisitionError {
|
||||
readonly _tag = "AcquisitionError"
|
||||
}
|
||||
const failing = node(Layer.effect(Value, Effect.fail(new AcquisitionError())), [])
|
||||
const exit = await Effect.runPromiseExit(Effect.provide(Value, build(failing)))
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(AcquisitionError)
|
||||
})
|
||||
|
||||
test("groups expose every selected service", async () => {
|
||||
class Count extends Context.Service<Count, { readonly value: number }>()("test/Count") {}
|
||||
const count = node(Layer.succeed(Count, Count.of({ value: 3 })), [])
|
||||
const result = Effect.gen(function* () {
|
||||
return { text: (yield* Value).value, count: (yield* Count).value }
|
||||
}).pipe(Effect.provide(build(group([value, count]))))
|
||||
|
||||
expect(await Effect.runPromise(result)).toEqual({ text: "production", count: 3 })
|
||||
})
|
||||
|
||||
test("builds an empty group", async () => {
|
||||
expect(await Effect.runPromise(Effect.succeed("ok").pipe(Effect.provide(build(group([])))))).toBe("ok")
|
||||
})
|
||||
|
||||
test("builds replacements with their own dependencies", async () => {
|
||||
class ReplacementConfig extends Context.Service<ReplacementConfig, { readonly value: string }>()(
|
||||
"test/ReplacementConfig",
|
||||
) {}
|
||||
const replacementConfig = node(Layer.succeed(ReplacementConfig, ReplacementConfig.of({ value: "replacement" })), [])
|
||||
const replacement = node(
|
||||
Layer.effect(
|
||||
Value,
|
||||
Effect.gen(function* () {
|
||||
return Value.of({ value: (yield* ReplacementConfig).value })
|
||||
}),
|
||||
),
|
||||
[replacementConfig],
|
||||
)
|
||||
const result = Effect.gen(function* () {
|
||||
return (yield* Greeting).text
|
||||
}).pipe(Effect.provide(build(greeting, { replacements: [replaceWithNode(value, replacement)] })))
|
||||
|
||||
expect(await Effect.runPromise(result)).toBe("hello replacement")
|
||||
})
|
||||
|
||||
test("does not acquire unreachable replacements", async () => {
|
||||
let acquisitions = 0
|
||||
const unreachable = node(Layer.succeed(Value, Value.of({ value: "unreachable" })), [])
|
||||
const replacement = Layer.effect(
|
||||
Value,
|
||||
Effect.sync(() => {
|
||||
acquisitions++
|
||||
return Value.of({ value: "replacement" })
|
||||
}),
|
||||
)
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.provide(Greeting, build(greeting, { replacements: [replace(unreachable, replacement)] })),
|
||||
)
|
||||
|
||||
expect(acquisitions).toBe(0)
|
||||
})
|
||||
|
||||
test("rejects a direct cycle", () => {
|
||||
const cyclic = node(Layer.succeed(Value, Value.of({ value: "cyclic" })), [])
|
||||
;(cyclic.dependencies as LayerNode.Node<unknown, unknown>[]).push(cyclic)
|
||||
|
||||
expect(() => build(cyclic)).toThrow("Cycle detected in app graph: layer#1 -> layer#1")
|
||||
})
|
||||
|
||||
test("rejects an indirect cycle", () => {
|
||||
const first = node(Layer.succeed(Value, Value.of({ value: "first" })), [])
|
||||
const second = node(Layer.succeed(Value, Value.of({ value: "second" })), [first])
|
||||
const third = node(Layer.succeed(Value, Value.of({ value: "third" })), [second])
|
||||
;(first.dependencies as LayerNode.Node<unknown, unknown>[]).push(third)
|
||||
|
||||
expect(() => build(first)).toThrow("Cycle detected in app graph: layer#1 -> layer#2 -> layer#3 -> layer#1")
|
||||
})
|
||||
|
||||
test("rejects a cycle introduced by a replacement", () => {
|
||||
const replacement = node(Layer.succeed(Value, Value.of({ value: "replacement" })), [])
|
||||
const consumer = node(greetingImplementation, [value])
|
||||
;(replacement.dependencies as LayerNode.Node<unknown, unknown>[]).push(consumer)
|
||||
|
||||
expect(() => build(consumer, { replacements: [replaceWithNode(value, replacement)] })).toThrow(
|
||||
"Cycle detected in app graph: layer#1 -> layer#2 -> layer#1",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { test } from "bun:test"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
|
||||
class A extends Context.Service<A, {}>()("test/TierA") {}
|
||||
class B extends Context.Service<B, {}>()("test/TierB") {}
|
||||
|
||||
const tiers = LayerNode.tiers(["request", "global"])
|
||||
const request = tiers.make("request")
|
||||
const global = tiers.make("global")
|
||||
const globalA = global({ service: A, layer: Layer.succeed(A, A.of({})), deps: [] })
|
||||
const bLayer = Layer.effect(B, Effect.as(A, B.of({})))
|
||||
|
||||
request({ service: B, layer: bLayer, deps: [globalA] })
|
||||
|
||||
// @ts-expect-error Global cannot depend on request
|
||||
global({ service: B, layer: bLayer, deps: [request({ service: A, layer: Layer.succeed(A, A.of({})), deps: [] })] })
|
||||
|
||||
test("type exploration compiles", () => {})
|
||||
@@ -0,0 +1,169 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
|
||||
class Value extends Context.Service<Value, { readonly value: string }>()("test/TierValue") {}
|
||||
class Result extends Context.Service<Result, { readonly value: string }>()("test/TierResult") {}
|
||||
class Left extends Context.Service<Left, { readonly value: string }>()("test/TierLeft") {}
|
||||
class Right extends Context.Service<Right, { readonly value: string }>()("test/TierRight") {}
|
||||
class Last extends Context.Service<Last, { readonly value: string }>()("test/TierLast") {}
|
||||
|
||||
test("builds tiers with a custom builder", async () => {
|
||||
let locationBuilds = 0
|
||||
const tiers = LayerNode.tiers(["location", "global"])
|
||||
const global = tiers.make("global")
|
||||
const location = tiers.make("location")
|
||||
const value = global({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "value" })), deps: [] })
|
||||
const result = location({
|
||||
service: Result,
|
||||
layer: Layer.effect(
|
||||
Result,
|
||||
Effect.gen(function* () {
|
||||
return Result.of({ value: (yield* Value).value })
|
||||
}),
|
||||
),
|
||||
deps: [value],
|
||||
})
|
||||
const layer = LayerNode.buildLayer(LayerNode.group([result]), {
|
||||
tiers,
|
||||
buildTier: (tier, layers) => {
|
||||
if (tier !== "location") return LayerNode.combine(layers)
|
||||
locationBuilds++
|
||||
return LayerNode.combine(layers).pipe(Layer.fresh)
|
||||
},
|
||||
})
|
||||
const program = Effect.gen(function* () {
|
||||
return (yield* Result).value
|
||||
}).pipe(Effect.provide(layer))
|
||||
|
||||
expect(await Effect.runPromise(program)).toBe("value")
|
||||
expect(locationBuilds).toBe(1)
|
||||
})
|
||||
|
||||
test("rejects conflicting higher-tier service implementations", () => {
|
||||
const tiers = LayerNode.tiers(["location", "global"])
|
||||
const global = tiers.make("global")
|
||||
const location = tiers.make("location")
|
||||
const first = global({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "first" })), deps: [] })
|
||||
const second = global({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "second" })), deps: [] })
|
||||
const left = location({
|
||||
service: Left,
|
||||
layer: Layer.effect(Left, Effect.as(Value, Left.of({ value: "left" }))),
|
||||
deps: [first],
|
||||
})
|
||||
const right = location({
|
||||
service: Right,
|
||||
layer: Layer.effect(Right, Effect.as(Value, Right.of({ value: "right" }))),
|
||||
deps: [second],
|
||||
})
|
||||
|
||||
expect(() => LayerNode.buildLayer(LayerNode.group([left, right]), { tiers })).toThrow(
|
||||
"conflicting implementations for test/TierValue",
|
||||
)
|
||||
})
|
||||
|
||||
test("validates tier dependencies through groups", () => {
|
||||
const tiers = LayerNode.tiers(["location", "global"])
|
||||
const global = tiers.make("global")
|
||||
const location = tiers.make("location")
|
||||
const local = location({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "local" })), deps: [] })
|
||||
const invalid = global({
|
||||
service: Result,
|
||||
layer: Layer.effect(
|
||||
Result,
|
||||
Effect.map(Value, (value) => Result.of({ value: value.value })),
|
||||
),
|
||||
deps: [LayerNode.group([local])],
|
||||
})
|
||||
|
||||
expect(() => LayerNode.buildLayer(invalid, { tiers })).toThrow("Tier global cannot depend on lower tier location")
|
||||
})
|
||||
|
||||
test("validates shared groups in each consumer tier", () => {
|
||||
const tiers = LayerNode.tiers(["location", "global"])
|
||||
const global = tiers.make("global")
|
||||
const location = tiers.make("location")
|
||||
const local = location({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "local" })), deps: [] })
|
||||
const shared = LayerNode.group([local])
|
||||
const valid = location({
|
||||
service: Left,
|
||||
layer: Layer.effect(
|
||||
Left,
|
||||
Effect.map(Value, (value) => Left.of({ value: value.value })),
|
||||
),
|
||||
deps: [shared],
|
||||
})
|
||||
const invalid = global({
|
||||
service: Result,
|
||||
layer: Layer.effect(
|
||||
Result,
|
||||
Effect.map(Value, (value) => Result.of({ value: value.value })),
|
||||
),
|
||||
deps: [shared],
|
||||
})
|
||||
|
||||
expect(() => LayerNode.buildLayer(LayerNode.group([valid, invalid]), { tiers })).toThrow(
|
||||
"Tier global cannot depend on lower tier location",
|
||||
)
|
||||
})
|
||||
|
||||
test("rejects a service assigned to multiple tiers", () => {
|
||||
const tiers = LayerNode.tiers(["location", "global"])
|
||||
const global = tiers.make("global")
|
||||
const location = tiers.make("location")
|
||||
const local = location({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "local" })), deps: [] })
|
||||
const shared = global({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "global" })), deps: [] })
|
||||
|
||||
expect(() => LayerNode.buildLayer(LayerNode.group([local, shared]), { tiers })).toThrow(
|
||||
"Service test/TierValue belongs to both tier location and tier global",
|
||||
)
|
||||
})
|
||||
|
||||
test("rebinds same-tier providers without reacquiring them", async () => {
|
||||
let firstAcquisitions = 0
|
||||
const tiers = LayerNode.tiers(["global"])
|
||||
const global = tiers.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 = LayerNode.buildLayer(LayerNode.group([left, right, last]), { tiers })
|
||||
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)
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { test } from "bun:test"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
|
||||
class A extends Context.Service<A, {}>()("test/LayerNodeA") {}
|
||||
class B extends Context.Service<B, {}>()("test/LayerNodeB") {}
|
||||
class C extends Context.Service<C, {}>()("test/LayerNodeC") {}
|
||||
class LayerError {
|
||||
readonly _tag = "LayerError"
|
||||
}
|
||||
class OtherError {
|
||||
readonly _tag = "OtherError"
|
||||
}
|
||||
|
||||
const tiers = LayerNode.tiers(["app"])
|
||||
const make = tiers.make("app")
|
||||
const aLayer = Layer.succeed(A, A.of({}))
|
||||
const bLayer = Layer.effect(B, Effect.as(A, B.of({})))
|
||||
const cLayer = Layer.effect(
|
||||
C,
|
||||
Effect.gen(function* () {
|
||||
yield* A
|
||||
yield* B
|
||||
return C.of({})
|
||||
}),
|
||||
)
|
||||
const failingA = Layer.effect(A, Effect.fail(new LayerError()))
|
||||
const a = make({ service: A, layer: aLayer, deps: [] })
|
||||
const b = make({ service: B, layer: bLayer, deps: [a] })
|
||||
const c = make({ service: C, layer: cLayer, deps: [a, b] })
|
||||
const failing = make({ service: A, layer: failingA, deps: [] })
|
||||
const dependent = make({ service: B, layer: bLayer, deps: [failing] })
|
||||
|
||||
make({ name: "manual-a", layer: aLayer, deps: [] })
|
||||
|
||||
// @ts-expect-error A node must have a service or name
|
||||
make({ layer: aLayer, deps: [] })
|
||||
|
||||
// @ts-expect-error Service and name are mutually exclusive
|
||||
make({ service: A, name: "a", layer: aLayer, deps: [] })
|
||||
|
||||
// @ts-expect-error B requires A
|
||||
make({ service: B, layer: bLayer, deps: [] })
|
||||
|
||||
// @ts-expect-error C requires A and B
|
||||
make({ service: C, layer: cLayer, deps: [a] })
|
||||
|
||||
const closed = LayerNode.buildLayer(c, { tiers })
|
||||
const closedWithError = LayerNode.buildLayer(dependent, { tiers })
|
||||
const checkClosed: Layer.Layer<C, never, never> = closed
|
||||
const checkError: Layer.Layer<B, LayerError, never> = closedWithError
|
||||
void checkClosed
|
||||
void checkError
|
||||
|
||||
LayerNode.replace(aLayer, Layer.succeed(A, A.of({})))
|
||||
|
||||
// @ts-expect-error Replacement must provide A
|
||||
LayerNode.replace(aLayer, Layer.succeed(B, B.of({})))
|
||||
|
||||
// @ts-expect-error Replacement cannot introduce a new error
|
||||
LayerNode.replace(aLayer, Layer.effect(A, Effect.fail(new OtherError())))
|
||||
|
||||
// @ts-expect-error Replacement must be closed
|
||||
LayerNode.replace(bLayer, bLayer)
|
||||
|
||||
test("type exploration compiles", () => {})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
|
||||
class Value extends Context.Service<Value, { readonly value: string }>()("test/LayerNodeValue") {}
|
||||
class Greeting extends Context.Service<Greeting, { readonly value: string }>()("test/LayerNodeGreeting") {}
|
||||
class Left extends Context.Service<Left, { readonly value: string }>()("test/LayerNodeLeft") {}
|
||||
class Right extends Context.Service<Right, { readonly value: string }>()("test/LayerNodeRight") {}
|
||||
|
||||
const tiers = LayerNode.tiers(["app"])
|
||||
const make = tiers.make("app")
|
||||
const valueLayer = Layer.succeed(Value, Value.of({ value: "production" }))
|
||||
const greetingLayer = Layer.effect(
|
||||
Greeting,
|
||||
Effect.map(Value, (value) => Greeting.of({ value: `hello ${value.value}` })),
|
||||
)
|
||||
const value = make({ service: Value, layer: valueLayer, deps: [] })
|
||||
const greeting = make({ service: Greeting, layer: greetingLayer, deps: [value] })
|
||||
|
||||
describe("layer node", () => {
|
||||
test("builds an untiered graph", async () => {
|
||||
const value = LayerNode.make({ service: Value, layer: valueLayer, deps: [] })
|
||||
const greeting = LayerNode.make({ service: Greeting, layer: greetingLayer, deps: [value] })
|
||||
const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(LayerNode.buildLayer(greeting)))
|
||||
expect(await Effect.runPromise(program)).toBe("hello production")
|
||||
})
|
||||
|
||||
test("builds a dependency graph", async () => {
|
||||
const program = Effect.map(Greeting, (item) => item.value).pipe(
|
||||
Effect.provide(LayerNode.buildLayer(greeting, { tiers })),
|
||||
)
|
||||
expect(await Effect.runPromise(program)).toBe("hello production")
|
||||
})
|
||||
|
||||
test("replaces a layer by identity", async () => {
|
||||
const replacement = Layer.succeed(Value, Value.of({ value: "simulation" }))
|
||||
const program = Effect.map(Greeting, (item) => item.value).pipe(
|
||||
Effect.provide(LayerNode.buildLayer(greeting, { tiers, replacements: [LayerNode.replace(valueLayer, replacement)] })),
|
||||
)
|
||||
expect(await Effect.runPromise(program)).toBe("hello simulation")
|
||||
})
|
||||
|
||||
test("replaces every use of the same layer", async () => {
|
||||
const leftLayer = Layer.effect(
|
||||
Left,
|
||||
Effect.map(Value, (item) => Left.of({ value: item.value })),
|
||||
)
|
||||
const rightLayer = Layer.effect(
|
||||
Right,
|
||||
Effect.map(Value, (item) => Right.of({ value: item.value })),
|
||||
)
|
||||
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 = LayerNode.buildLayer(LayerNode.group([left, right]), {
|
||||
tiers,
|
||||
replacements: [LayerNode.replace(valueLayer, replacement)],
|
||||
})
|
||||
const program = Effect.gen(function* () {
|
||||
return [(yield* Left).value, (yield* Right).value]
|
||||
}).pipe(Effect.provide(layer))
|
||||
expect(await Effect.runPromise(program)).toEqual(["replaced", "replaced"])
|
||||
})
|
||||
|
||||
test("does not acquire an unused replacement", async () => {
|
||||
let acquisitions = 0
|
||||
const other = Layer.succeed(Value, Value.of({ value: "other" }))
|
||||
const replacement = Layer.effect(
|
||||
Value,
|
||||
Effect.sync(() => {
|
||||
acquisitions++
|
||||
return Value.of({ value: "replacement" })
|
||||
}),
|
||||
)
|
||||
await Effect.runPromise(
|
||||
Effect.map(Greeting, (item) => item.value).pipe(
|
||||
Effect.provide(LayerNode.buildLayer(greeting, { tiers, replacements: [LayerNode.replace(other, replacement)] })),
|
||||
),
|
||||
)
|
||||
expect(acquisitions).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { test } from "bun:test"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/core/effect/scoped-node"
|
||||
|
||||
class A extends Context.Service<A, {}>()("test/ScopedA") {}
|
||||
class B extends Context.Service<B, {}>()("test/ScopedB") {}
|
||||
|
||||
const a = Layer.succeed(A, A.of({}))
|
||||
const b = Layer.effect(B, Effect.as(A, B.of({})))
|
||||
const globalA = makeGlobalNode({ service: A, layer: a, deps: [] })
|
||||
const locationA = makeLocationNode({ service: A, layer: a, deps: [] })
|
||||
|
||||
makeGlobalNode({ service: B, layer: b, deps: [globalA] })
|
||||
makeLocationNode({ service: B, layer: b, deps: [globalA] })
|
||||
makeLocationNode({ service: B, layer: b, deps: [locationA] })
|
||||
|
||||
// @ts-expect-error Global nodes cannot depend on location nodes
|
||||
makeGlobalNode({ service: B, layer: b, deps: [locationA] })
|
||||
|
||||
// @ts-expect-error B requires A
|
||||
makeLocationNode({ service: B, layer: b, deps: [] })
|
||||
|
||||
test("type exploration compiles", () => {})
|
||||
@@ -177,10 +177,13 @@ const root = LayerNode.group([
|
||||
CrossSpawnSpawner.node,
|
||||
])
|
||||
const replacements = [
|
||||
LayerNode.replace(SessionSummary.node, summary),
|
||||
LayerNode.replace(RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })),
|
||||
LayerNode.replace(SessionSummary.layer, summary),
|
||||
LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer({ experimentalEventSystem: true })),
|
||||
]
|
||||
const env = LayerNode.buildLayer(LayerNode.group([root, LayerNode.make(TestLLMServer.layer, [])]), { replacements })
|
||||
const env = LayerNode.buildLayer(
|
||||
LayerNode.group([root, LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] })]),
|
||||
{ replacements },
|
||||
)
|
||||
|
||||
const it = testEffect(env)
|
||||
|
||||
@@ -205,7 +208,7 @@ const providerErrorLLM = Layer.succeed(
|
||||
}),
|
||||
)
|
||||
const providerErrorEnv = LayerNode.buildLayer(root, {
|
||||
replacements: [...replacements, LayerNode.replace(LLM.node, providerErrorLLM)],
|
||||
replacements: [...replacements, LayerNode.replace(LLM.layer, providerErrorLLM)],
|
||||
})
|
||||
const itProviderError = testEffect(providerErrorEnv)
|
||||
|
||||
@@ -224,7 +227,7 @@ const fragmentFailureLLM = Layer.succeed(
|
||||
}),
|
||||
)
|
||||
const fragmentFailureEnv = LayerNode.buildLayer(root, {
|
||||
replacements: [...replacements, LayerNode.replace(LLM.node, fragmentFailureLLM)],
|
||||
replacements: [...replacements, LayerNode.replace(LLM.layer, fragmentFailureLLM)],
|
||||
})
|
||||
const itFragmentFailure = testEffect(fragmentFailureEnv)
|
||||
|
||||
|
||||
@@ -84,14 +84,14 @@ const root = LayerNode.group([
|
||||
SessionSummary.node,
|
||||
Database.node,
|
||||
CrossSpawnSpawner.node,
|
||||
LayerNode.make(TestLLMServer.layer, []),
|
||||
LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] }),
|
||||
])
|
||||
const it = testEffect(
|
||||
LayerNode.buildLayer(root, {
|
||||
replacements: [
|
||||
LayerNode.replace(MCP.node, mcp),
|
||||
LayerNode.replace(LSP.node, lsp),
|
||||
LayerNode.replace(RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })),
|
||||
LayerNode.replace(MCP.layer, mcp),
|
||||
LayerNode.replace(LSP.layer, lsp),
|
||||
LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer({ experimentalEventSystem: true })),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Layer, Option } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { httpClient } from "@opencode-ai/core/effect/layer-node-platform"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
@@ -35,7 +35,7 @@ const none = HttpClient.make(() => Effect.die("unexpected http call"))
|
||||
|
||||
function requestLayer(client: HttpClient.HttpClient) {
|
||||
return LayerNode.buildLayer(LayerNode.group([ShareNext.node, AccountRepo.node]), {
|
||||
replacements: [LayerNode.replace(httpClient, Layer.succeed(HttpClient.HttpClient, client))],
|
||||
replacements: [LayerNode.replace(FetchHttpClient.layer, Layer.succeed(HttpClient.HttpClient, client))],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -49,9 +49,7 @@ function integrationLayer(client: HttpClient.HttpClient) {
|
||||
AccountRepo.node,
|
||||
Database.node,
|
||||
]),
|
||||
{
|
||||
replacements: [LayerNode.replace(httpClient, Layer.succeed(HttpClient.HttpClient, client))],
|
||||
},
|
||||
{ replacements: [LayerNode.replace(FetchHttpClient.layer, Layer.succeed(HttpClient.HttpClient, client))] },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -50,14 +50,14 @@ const brokenPluginLayer = Layer.succeed(
|
||||
|
||||
const root = LayerNode.group([ToolRegistry.node, Agent.node])
|
||||
const replacements = [
|
||||
LayerNode.replace(Config.node, configLayer),
|
||||
LayerNode.replace(RuntimeFlags.node, RuntimeFlags.layer()),
|
||||
LayerNode.replace(Config.layer, configLayer),
|
||||
LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer()),
|
||||
]
|
||||
|
||||
const it = testEffect(LayerNode.buildLayer(root, { replacements }))
|
||||
const withBrokenPlugin = testEffect(
|
||||
LayerNode.buildLayer(root, {
|
||||
replacements: [...replacements, LayerNode.replace(Plugin.node, brokenPluginLayer)],
|
||||
replacements: [...replacements, LayerNode.replace(Plugin.layer, brokenPluginLayer)],
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
# Layer Node Tiers
|
||||
|
||||
## Goal
|
||||
|
||||
`LayerNode` describes the complete dependency graph while allowing groups of nodes to be constructed with different lifecycle boundaries. The abstraction must not hard-code concepts such as global or location services.
|
||||
|
||||
## Node Definition
|
||||
|
||||
Nodes have an Effect service tag, a layer, dependencies, and a tier. The service tag's runtime `key` identifies the node in diagnostics:
|
||||
|
||||
```ts
|
||||
export const node = LayerNode.make({
|
||||
service: Watcher.Service,
|
||||
layer,
|
||||
deps: [],
|
||||
tier: location,
|
||||
})
|
||||
```
|
||||
|
||||
Tier-specific makers supply the tier automatically:
|
||||
|
||||
```ts
|
||||
export const node = makeLocationNode({
|
||||
service: Watcher.Service,
|
||||
layer,
|
||||
deps: [Config.node, Git.node],
|
||||
})
|
||||
```
|
||||
|
||||
## Tier Declaration
|
||||
|
||||
Tiers are declared bottom-up, from the most specific lifecycle to the most foundational:
|
||||
|
||||
```ts
|
||||
const tiers = LayerNode.tiers([
|
||||
"location",
|
||||
"global",
|
||||
])
|
||||
```
|
||||
|
||||
An earlier tier may depend on its own tier or any later tier. A later tier cannot depend on an earlier tier.
|
||||
|
||||
For the example above:
|
||||
|
||||
- `location` may depend on `location` or `global`.
|
||||
- `global` may depend only on `global`.
|
||||
|
||||
### Cross-Tier Dependencies
|
||||
|
||||
Dependencies on a later, more foundational tier are hoisted outside the current tier's construction boundary. For example, global dependencies of location nodes must be built outside a location `Layer.fresh` boundary.
|
||||
|
||||
From the perspective of a lower tier, each service crossing into a higher tier must resolve to one unique node identity:
|
||||
|
||||
- Multiple consumers may depend on the same higher-tier node.
|
||||
- Two different higher-tier nodes for the same service are a conflict, even if both satisfy the same dependency type.
|
||||
- This constraint applies to transitive higher-tier dependencies as well as direct dependencies.
|
||||
|
||||
This validation happens while traversing dependency edges for the single complete-graph topological sort. It is not reconstructed from the flattened sorted list and is not a separate validation pass.
|
||||
|
||||
The traversal must retain the lower-tier perspective when following a dependency into a higher tier. For each lower tier, it tracks the higher-tier node selected for every service key. Reaching the same service through the same node identity is valid; reaching it through a different node identity is a conflict.
|
||||
|
||||
Topological visitation and boundary validation are distinct traversal state:
|
||||
|
||||
- A node is emitted into the topological order once.
|
||||
- A higher-tier node may need boundary validation once for each lower tier from which it is reachable.
|
||||
|
||||
This distinction is required for transitive dependencies. A higher-tier node may already be topologically visited when another lower-tier branch reaches it, but that later branch must still participate in service uniqueness validation.
|
||||
|
||||
The tier configuration generates correctly constrained makers:
|
||||
|
||||
```ts
|
||||
export const makeLocationNode = tiers.make("location")
|
||||
export const makeGlobalNode = tiers.make("global")
|
||||
```
|
||||
|
||||
This must reject invalid dependencies at compile time:
|
||||
|
||||
```ts
|
||||
makeGlobalNode({
|
||||
service: Database.Service,
|
||||
layer,
|
||||
deps: [locationNode], // type error
|
||||
})
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
`buildLayer` remains a top-level `LayerNode` function and receives the tier configuration:
|
||||
|
||||
```ts
|
||||
const appLayer = LayerNode.buildLayer(root, tiers)
|
||||
```
|
||||
|
||||
It performs these steps:
|
||||
|
||||
1. Traverse and topologically sort the complete reachable graph once, dependency-first.
|
||||
2. While traversing dependency edges, detect cycles, validate tier direction, and validate unique cross-tier service implementations from each lower tier's perspective.
|
||||
3. Partition the one sorted list by tier while preserving its relative order.
|
||||
4. Process tiers in their declared bottom-up order.
|
||||
5. Build one layer for each tier.
|
||||
6. Connect tier layers with `Layer.provideMerge` according to tier dependencies.
|
||||
7. Return one final closed Effect layer.
|
||||
|
||||
### One Topological Sort
|
||||
|
||||
There is one topological sort for the entire graph, not one sort per tier. Every reachable node is emitted into the sorted result once. Boundary-validation state may separately process a higher-tier node once per originating lower tier; this does not create another topological sort.
|
||||
|
||||
For example, one dependency-first result may be:
|
||||
|
||||
```text
|
||||
[globalDatabase, globalGit, locationConfig, locationWatcher]
|
||||
```
|
||||
|
||||
Stable partitioning then produces:
|
||||
|
||||
```text
|
||||
global: [globalDatabase, globalGit]
|
||||
location: [locationConfig, locationWatcher]
|
||||
```
|
||||
|
||||
Because partitioning preserves relative order, dependencies within each tier remain before their consumers. Cross-tier dependencies were already validated while their dependency edges were available during traversal; validation is not attempted from the partitioned lists.
|
||||
|
||||
### Provider Rebinding Within A Tier
|
||||
|
||||
Topological node deduplication is not sufficient when a tier contains different nodes that provide the same service. The final linear layer plan must preserve which implementation each consumer depends on.
|
||||
|
||||
For example:
|
||||
|
||||
```text
|
||||
ConsumerX -> X provides Service
|
||||
ConsumerY -> Y provides Service
|
||||
ConsumerX2 -> X provides Service
|
||||
```
|
||||
|
||||
The resulting dependency-first plan must be able to represent:
|
||||
|
||||
```text
|
||||
ConsumerX, X, ConsumerY, Y, ConsumerX2, X
|
||||
```
|
||||
|
||||
After `Y` becomes the active implementation, the later dependency on `X` must emit `X` again. A global visited set must not incorrectly remove that second placement.
|
||||
|
||||
While constructing a tier's linear plan, track the active provider node for each service key:
|
||||
|
||||
- If the required provider is already active, its repeated placement may be omitted.
|
||||
- If a different provider for the same service is active, emit the required provider again and make it active.
|
||||
- If no provider is active, emit the provider and make it active.
|
||||
|
||||
Repeated placement of the same node does not imply repeated resource acquisition. Effect layer memoization may still reuse the same layer instance. The repeated placement restores the intended provider binding for subsequent consumers.
|
||||
|
||||
This differs from cross-tier uniqueness. Multiple implementations may be rebound within one tier, but different implementations of the same service cannot both be hoisted across a tier boundary.
|
||||
|
||||
Without a custom build function, a tier's sorted layers are combined with the default `Layer.provideMerge` behavior.
|
||||
|
||||
## Custom Tier Build Function
|
||||
|
||||
The optional third argument customizes how each tier's sorted layers are constructed:
|
||||
|
||||
```ts
|
||||
const appLayer = LayerNode.buildLayer(
|
||||
root,
|
||||
tiers,
|
||||
(tier, layers) => {
|
||||
const combined = LayerNode.combine(layers)
|
||||
|
||||
if (tier !== "location") return combined
|
||||
|
||||
return Layer.effect(
|
||||
LocationServiceMap,
|
||||
LayerMap.make(
|
||||
(ref: Location.Ref) =>
|
||||
combined.pipe(
|
||||
Layer.provide(Location.layer(ref)),
|
||||
Layer.fresh,
|
||||
),
|
||||
{ idleTimeToLive: "60 minutes" },
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
The callback receives:
|
||||
|
||||
- The tier name.
|
||||
- The tier's layers in the dependency-first order preserved from the single complete-graph topological sort.
|
||||
|
||||
It returns the final layer representing that tier. This permits a tier to introduce a lifecycle boundary, wrap its layers in a `LayerMap`, or otherwise transform how the tier is built.
|
||||
|
||||
## Replacements
|
||||
|
||||
Tests and alternate runtimes may replace a specific layer implementation by exact object identity:
|
||||
|
||||
```ts
|
||||
const layer = LayerNode.buildLayer(
|
||||
root,
|
||||
tiers,
|
||||
buildTier,
|
||||
[LayerNode.replace(Config.layer, testConfigLayer)],
|
||||
)
|
||||
```
|
||||
|
||||
The replacement applies to every placement of that exact source layer in the generated plans. Unused replacements are not acquired. A replacement must provide the same service output, must not introduce new errors, and must not have unresolved dependencies.
|
||||
|
||||
## Freshness
|
||||
|
||||
Global implementations must remain outside the location freshness boundary. Conceptually:
|
||||
|
||||
```ts
|
||||
locationTier
|
||||
.pipe(Layer.fresh)
|
||||
.pipe(Layer.provideMerge(globalTier))
|
||||
```
|
||||
|
||||
The location tier contains only location implementations. Global dependencies are connected after the location build function creates its fresh or `LayerMap` boundary, so global services remain shared.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
`LayerNode` owns:
|
||||
|
||||
- Service tags and dependency edges.
|
||||
- Tier declarations and type-safe tier makers.
|
||||
- Cycle detection and diagnostics using service keys such as `Watcher.Service.key`.
|
||||
- One dependency-first topological sort of the complete graph.
|
||||
- Cross-tier service uniqueness validation during dependency traversal, tracked per originating lower tier.
|
||||
- Stable partitioning of the sorted nodes by tier.
|
||||
- Provider-aware linearization within each tier, including rebinding when different nodes provide the same service.
|
||||
- Invoking the optional tier build function.
|
||||
- Wiring the resulting tier layers into one final layer.
|
||||
|
||||
The caller owns:
|
||||
|
||||
- The meaning of each tier.
|
||||
- Tier-specific lifecycle behavior.
|
||||
- Specialized wrappers such as `LocationServiceMap`.
|
||||
|
||||
The abstraction must not contain built-in knowledge of global, location, request, workspace, or other application-specific tiers.
|
||||
|
||||
## Deferred: packages/opencode Compatibility
|
||||
|
||||
The first implementation will not migrate or redesign the existing `packages/opencode` integration with core's `LocationServiceMap`.
|
||||
|
||||
`packages/opencode` currently uses its own `InstanceState` lifecycle while bridging to core location services through `LocationServiceMap`. Production consumers include:
|
||||
|
||||
- `packages/opencode/src/session/system.ts`
|
||||
- `packages/opencode/src/agent/agent.ts`
|
||||
- `packages/opencode/src/cli/cmd/debug/file.ts`
|
||||
- `packages/opencode/src/cli/cmd/debug/v2.ts`
|
||||
- `packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts`
|
||||
- `packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts`
|
||||
|
||||
Some consumers wrap `LocationServiceMap.layer` as an opaque `LayerNode`; others provide the layer directly. We need to determine how these bridges consume the tier-built core graph and how unresolved global dependencies are exposed after the new core location builder is implemented.
|
||||
|
||||
This compatibility work must happen after the first tier implementation. The first implementation should preserve existing `packages/opencode` behavior and avoid changing these bridges.
|
||||
Reference in New Issue
Block a user