diff --git a/packages/core/package.json b/packages/core/package.json index 139d116a781..62d279d311c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -16,6 +16,8 @@ "opencode": "./bin/opencode" }, "exports": { + "./effect/layer-node": "./src/effect/layer-node/index.ts", + "./effect/node": "./src/effect/node.ts", "./session/runner": "./src/session/runner/index.ts", "./system-context": "./src/system-context/index.ts", "./*": "./src/*.ts" diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 91f411684a0..6173551a253 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -1,5 +1,6 @@ export * as AgentV2 from "./agent" +import { makeLocationNode } from "./effect/node" import { Array, Context, Effect, Layer, Types } from "effect" import { Agent } from "@opencode-ai/schema/agent" import { State } from "./state" @@ -106,3 +107,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 9ea79394f34..26bcef0ac9d 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -1,5 +1,6 @@ export * as AISDK from "./aisdk" +import { makeLocationNode } from "./effect/node" import type { LanguageModelV3 } from "@ai-sdk/provider" import { Cause, Context, Effect, Layer, Schema, Scope } from "effect" import { ModelV2 } from "./model" @@ -231,4 +232,6 @@ export const locationLayer = Layer.effect( }), ) +export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] }) + export const defaultLayer = locationLayer diff --git a/packages/core/src/background-job.ts b/packages/core/src/background-job.ts index 35724eb8fd6..98ba12e5154 100644 --- a/packages/core/src/background-job.ts +++ b/packages/core/src/background-job.ts @@ -2,6 +2,7 @@ export * as BackgroundJob from "./background-job" import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect" import { Identifier } from "./id/id" +import { makeGlobalNode } from "./effect/node" export type Status = "running" | "completed" | "error" | "cancelled" @@ -362,3 +363,5 @@ export const make = Effect.gen(function* () { export const layer = Layer.effect(Service, make) export const defaultLayer = layer + +export const node = makeGlobalNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 63d0ae37cbf..f63f3f0e372 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -1,5 +1,6 @@ export * as Catalog from "./catalog" +import { makeLocationNode } from "./effect/node" import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect" import { Catalog } from "@opencode-ai/schema/catalog" import { ModelV2 } from "./model" @@ -296,3 +297,5 @@ export const locationLayer = layer.pipe( Layer.provideMerge(Integration.locationLayer), Layer.provideMerge(Policy.locationLayer), ) + +export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Policy.node, Integration.node] }) diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts index c65693ebfdb..b106eab040b 100644 --- a/packages/core/src/command.ts +++ b/packages/core/src/command.ts @@ -1,5 +1,6 @@ export * as CommandV2 from "./command" +import { makeLocationNode } from "./effect/node" import { Context, Effect, Layer, Types } from "effect" import { Command } from "@opencode-ai/schema/command" import { State } from "./state" @@ -59,3 +60,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 1f97194ad5e..979386c93d9 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -1,5 +1,6 @@ export * as Config from "./config" +import { makeLocationNode } from "./effect/node" import path from "path" import { type ParseError, parse } from "jsonc-parser" import { Context, Effect, Layer, Option, Schema } from "effect" @@ -218,3 +219,9 @@ export const layer = Layer.effect( ) export const locationLayer = layer.pipe(Layer.provideMerge(Policy.locationLayer)) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [FSUtil.node, Global.node, Location.node, Policy.node], +}) diff --git a/packages/core/src/credential.ts b/packages/core/src/credential.ts index caef1db1b88..1fa6374b707 100644 --- a/packages/core/src/credential.ts +++ b/packages/core/src/credential.ts @@ -5,6 +5,7 @@ import { Context, Effect, Layer, Schema } from "effect" import { Credential } from "@opencode-ai/schema/credential" import { Integration } from "@opencode-ai/schema/integration" import { Database } from "./database/database" +import { makeGlobalNode } from "./effect/node" import { CredentialTable } from "./credential/sql" export const ID = Credential.ID @@ -135,3 +136,5 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) + +export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] }) diff --git a/packages/core/src/cross-spawn-spawner.ts b/packages/core/src/cross-spawn-spawner.ts index 370c07a648b..f3b8d067f56 100644 --- a/packages/core/src/cross-spawn-spawner.ts +++ b/packages/core/src/cross-spawn-spawner.ts @@ -24,7 +24,7 @@ import { import * as NodeChildProcess from "node:child_process" import { PassThrough } from "node:stream" import launch from "cross-spawn" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/node" import { filesystem, path } from "./effect/layer-node-platform" const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err))) @@ -503,6 +503,6 @@ export const layer: Layer.Layer @@ -60,4 +60,4 @@ export const defaultLayer = Layer.unwrap( }), ).pipe(Layer.provide(Global.defaultLayer)) -export const node = LayerNode.make({ service: Service, layer: layerFromPath(path()), deps: [] }) +export const node = makeGlobalNode({ service: Service, layer: layerFromPath(path()), deps: [] }) diff --git a/packages/core/src/effect/layer-node-platform.ts b/packages/core/src/effect/layer-node-platform.ts index ac64943e468..9bd6b1c2537 100644 --- a/packages/core/src/effect/layer-node-platform.ts +++ b/packages/core/src/effect/layer-node-platform.ts @@ -3,16 +3,16 @@ 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" +import { makeGlobalNode } from "./node" -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({ +export const filesystem = makeGlobalNode({ service: FileSystem.FileSystem, layer: NodeFileSystem.layer, deps: [] }) +export const path = makeGlobalNode({ service: Path.Path, layer: NodePath.layer, deps: [] }) +export const httpClient = makeGlobalNode({ service: HttpClient.HttpClient, layer: FetchHttpClient.layer, deps: [] }) +export const requestExecutor = makeGlobalNode({ service: RequestExecutor.Service, layer: RequestExecutor.layer, deps: [httpClient], }) -export const llmClient = LayerNode.make({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] }) +export const llmClient = makeGlobalNode({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] }) export * as LayerNodePlatform from "./layer-node-platform" diff --git a/packages/core/src/effect/layer-node.ts b/packages/core/src/effect/layer-node.ts deleted file mode 100644 index 587274d6c2c..00000000000 --- a/packages/core/src/effect/layer-node.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { Brand, Context, Layer } from "effect" - -type RuntimeLayer = Layer.Layer -type AnyNode = Node -type NodeList = readonly [] | readonly [Item, ...Item[]] -type Output = [Item] extends [never] ? never : Item extends Node ? A : never -type Error = [Item] extends [never] ? never : Item extends Node ? E : never -type Missing = Exclude> -type CheckDependencies = [ - Missing, Dependencies>, -] extends [never] - ? unknown - : { readonly "Missing dependencies": Missing, Dependencies> } -declare const $OutputType: unique symbol -declare const $ErrorType: unique symbol - -export type Tier = Name & Brand.Brand<"LayerNode.Tier"> - -const makeTier = Brand.nominal() - -export type Node = { - 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 -} - -type NodeIdentity = - | { readonly service: Context.Service.Any; readonly name?: never } - | { readonly name: string; readonly service?: never } -type DistributiveOmit = A extends unknown ? Omit : never - -type NodeInput< - Implementation extends Layer.Any, - Items extends NodeList, - T extends Tier | undefined = undefined, -> = NodeIdentity & { - readonly layer: Implementation - readonly deps: Items & CheckDependencies> - readonly tier?: T -} - -export function make< - const Implementation extends Layer.Any, - const Items extends NodeList, - const T extends Tier | undefined = undefined, ->( - input: NodeInput, -): Node, Layer.Error | Error, 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( - dependencies: Items, -): Node, Error> { - return { kind: "group", name: "group", dependencies } -} - -type AllowedTierNames = Names extends readonly [ - infer Head extends string, - ...infer Tail extends readonly string[], -] - ? Head extends Name - ? Head | Tail[number] - : AllowedTierNames - : never - -type NodeInTiers = Node> - -export interface Tiers { - readonly names: Names - readonly values: { readonly [K in Names[number]]: Tier } - readonly make: ( - name: Name, - ) => < - const Implementation extends Layer.Any, - const Items extends NodeList>>, - >( - input: DistributiveOmit>, "tier">, - ) => Node, Layer.Error | Error, Tier> -} - -export function tiers(names: Names): Tiers { - const values = Object.fromEntries(names.map((name) => [name, makeTier(name)])) as Tiers["values"] - return { - names, - values, - make: ((name: Names[number]) => (input: DistributiveOmit, "tier">) => - make({ ...input, tier: values[name] })) as Tiers["make"], - } -} - -const defaultTiers = tiers(["untiered"]) -const untiered = defaultTiers.values.untiered - -export type Replacement = { - readonly source: Layer.Any - readonly replacement: Layer.Any -} - -type CheckReplacementErrors = [Exclude] extends [never] - ? unknown - : { readonly "New replacement errors": Exclude } - -export function replace( - source: Layer.Layer, - replacement: Layer.Layer, E2, never> & CheckReplacementErrors>, -): Replacement { - return { source, replacement } -} - -export function buildLayer< - A, - E, - const Names extends readonly [string, ...string[]] = readonly ["untiered"], - const Built extends Layer.Any = Layer.Layer, ->( - node: Node, - options?: { - readonly tiers?: Tiers - readonly buildTier?: (tier: Names[number], layers: readonly Layer.Any[]) => Built - readonly replacements?: readonly Replacement[] - }, -): Layer.Layer, E | Layer.Error, never> { - const tiers = options?.tiers ?? (defaultTiers as unknown as Tiers) - 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 combine(layers: readonly Layer.Any[]): RuntimeLayer { - return layers.reduce( - (result, layer) => (layer as RuntimeLayer).pipe(Layer.provideMerge(result)), - Layer.empty as RuntimeLayer, - ) -} - -function plan( - root: AnyNode, - tiers: Tiers, - replacements: ReadonlyMap, -) { - const indexes = new Map(tiers.names.map((name, index) => [tiers.values[name], index])) - const plans = new Map() - const activeImplementations = new Map>() - const serviceTiers = new Map() - const visiting = new Set() - const stack: AnyNode[] = [] - const boundaryVisited = new Map>() - const boundaryServices = new Map>() - - const validateBoundary = (node: AnyNode, origin: Tier) => { - const checked = boundaryVisited.get(node) ?? new Set() - boundaryVisited.set(node, checked) - if (checked.has(origin)) return false - checked.add(origin) - const services = boundaryServices.get(origin) ?? new Map() - 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() - activeImplementations.set(tier, implementations) - if (implementations.get(key) === node && unseenOrigins.length === 0) return - - if (visiting.has(node)) { - const start = stack.indexOf(node) - throw new Error( - `Cycle detected in layer graph: ${[...stack.slice(start), node].map((item) => item.name).join(" -> ")}`, - ) - } - - visiting.add(node) - stack.push(node) - try { - 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) - } - } - - visit(root) - return plans -} - -function requireTier(node: AnyNode, indexes: ReadonlyMap) { - 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" diff --git a/packages/core/src/effect/layer-node/index.ts b/packages/core/src/effect/layer-node/index.ts new file mode 100644 index 00000000000..a031217e125 --- /dev/null +++ b/packages/core/src/effect/layer-node/index.ts @@ -0,0 +1,2 @@ +export * from "./layer-node" +export * as LayerNodeTree from "./layer-node-tree" diff --git a/packages/core/src/effect/layer-node/layer-node-tree.ts b/packages/core/src/effect/layer-node/layer-node-tree.ts new file mode 100644 index 00000000000..44df9d22f4f --- /dev/null +++ b/packages/core/src/effect/layer-node/layer-node-tree.ts @@ -0,0 +1,118 @@ +import { Layer } from "effect" +import { LayerNode } from "./layer-node" + +type AnyNode = LayerNode.Node +type RuntimeLayer = Layer.Layer + +export function hoist( + root: LayerNode.Node, + tag: T, +): { + readonly node: LayerNode.Node + readonly hoisted: LayerNode.Node +} { + const visited = new Map() + const hoisted = new Map() + const visiting = new Set() + const stack: AnyNode[] = [] + + const visit = (node: AnyNode): AnyNode => { + if (node.kind === "group") { + return { ...node, dependencies: node.dependencies.map(visit) } + } + + const existingNode = visited.get(node) + if (existingNode) return existingNode + + if (node.tag === tag) { + const existing = hoisted.get(node.name) + if (existing && existing !== node) { + throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`) + } + hoisted.set(node.name, node) + const empty = LayerNode.group([]) + visited.set(node, empty) + return empty + } + if (node.kind === "unbound") { + return node + } + + if (visiting.has(node)) { + const start = stack.indexOf(node) + throw new Error( + `Cycle detected in layer tree: ${[...stack.slice(start), node].map((item) => item.name).join(" -> ")}`, + ) + } + visiting.add(node) + stack.push(node) + try { + const dependencies = node.dependencies.map(visit) + const clone = { ...node, dependencies } + visited.set(node, clone) + return clone + } finally { + stack.pop() + visiting.delete(node) + } + } + + return { + node: visit(root) as LayerNode.Node, + hoisted: LayerNode.group(Array.from(hoisted.values())) as LayerNode.Node, + } +} + +export function compile( + root: LayerNode.Node, + replacements?: ReadonlyMap, +): Layer.Layer { + const cache = new Map() + const compileNode = (node: AnyNode): RuntimeLayer => { + if (node.kind === "unbound") throw new Error(`Unbound layer node: ${node.name}`) + const cached = cache.get(node) + if (cached) return cached + const dependencies = node.dependencies.flatMap(flatten).map(compileNode) + const implementation = (replacements?.get(node.implementation!) ?? node.implementation!) as RuntimeLayer + const layer = + dependencies.length === 0 + ? implementation + : implementation.pipe(Layer.provide(dependencies as [RuntimeLayer, ...RuntimeLayer[]])) + cache.set(node, layer) + return layer + } + const layers = flatten(root).map((node) => compileNode(node)) + const layer = layers.reduce((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty) + return layer as Layer.Layer +} + +export function bind( + root: LayerNode.Node, + source: AnyNode, + replacement: AnyNode, +): LayerNode.Node { + if (source.kind !== "unbound") throw new Error(`Cannot bind non-unbound layer node: ${source.name}`) + if (source.name !== replacement.name) { + throw new Error(`Cannot bind ${source.name} to ${replacement.name}`) + } + if (source.tag !== replacement.tag) { + throw new Error(`Cannot bind ${source.name} across tags`) + } + const visited = new Map() + const visit = (node: AnyNode): AnyNode => { + if (node === source) return replacement + const existing = visited.get(node) + if (existing) return existing + if (node.kind === "unbound") return node + const clone = { ...node, dependencies: node.dependencies.map(visit) } + visited.set(node, clone) + return clone + } + return visit(root) as LayerNode.Node +} + +function flatten(node: AnyNode): readonly AnyNode[] { + return node.kind === "group" ? node.dependencies.flatMap(flatten) : [node] +} + +export * as LayerNodeTree from "./layer-node-tree" diff --git a/packages/core/src/effect/layer-node/layer-node.ts b/packages/core/src/effect/layer-node/layer-node.ts new file mode 100644 index 00000000000..52ed1a9faed --- /dev/null +++ b/packages/core/src/effect/layer-node/layer-node.ts @@ -0,0 +1,138 @@ +import { Brand, Context, Layer } from "effect" + +type AnyNode = Node +type NodeList = readonly [] | readonly [Item, ...Item[]] +export type Output = [Item] extends [never] ? never : Item extends Node ? A : never +export type Error = [Item] extends [never] ? never : Item extends Node ? E : never +type NodeTag = [Item] extends [never] ? undefined : Item extends Node ? T : never +type Missing = Exclude> +type CheckDependencies = [ + Missing, Dependencies>, +] extends [never] + ? unknown + : { readonly "Missing dependencies": Missing, Dependencies> } +declare const $OutputType: unique symbol +declare const $ErrorType: unique symbol + +export type Tag = Name & Brand.Brand<"LayerNode.Tag"> + +const makeTag = Brand.nominal() + +export interface Node { + readonly kind: "layer" | "unbound" | "group" + readonly name: string + readonly service?: Context.Service.Any + readonly implementation?: Layer.Any + readonly dependencies: readonly AnyNode[] + readonly tag?: T + readonly [$OutputType]?: () => A + readonly [$ErrorType]?: () => E +} + +type NodeIdentity = + | { readonly service: Context.Service.Any; readonly name?: never } + | { readonly name: string; readonly service?: never } +type DistributiveOmit = A extends unknown ? Omit : never + +type MakeInput< + Implementation extends Layer.Any, + Items extends NodeList, + T extends Tag | undefined = undefined, +> = NodeIdentity & { + readonly layer: Implementation + readonly deps: Items & CheckDependencies> + readonly tag?: T +} + +export function make< + const Implementation extends Layer.Any, + const Items extends NodeList, + const T extends Tag | undefined = undefined, +>( + input: MakeInput, +): Node< + Layer.Success, + Layer.Error | Error, + T +> { + return { + kind: "layer", + name: input.service !== undefined ? input.service.key : input.name, + service: input.service, + implementation: input.layer, + dependencies: input.deps, + tag: input.tag, + } +} + +export function unbound( + service: Context.Key, + tag: T, +): Node { + return { + kind: "unbound", + name: service.key, + service, + dependencies: [], + tag, + } +} + +export function group( + dependencies: Items, +): Node, Error, NodeTag> { + return { kind: "group", name: "group", dependencies } +} + +export type TagConfig = Readonly> +type TagNames = keyof Config & string +type NodeInTags = Node | undefined> +type CheckTags = [Exclude>] extends [ + never, +] + ? unknown + : { readonly "Invalid tag dependencies": Exclude> } + +export interface Tags { + readonly values: { readonly [Name in TagNames]: Tag } + readonly make: >( + name: Name, + ) => ( + input: DistributiveOmit>, "tag"> & + CheckTags>, + ) => Node< + Layer.Success, + Layer.Error | Error, + Tag + > +} + +export function tags< + const Config extends { readonly [Name in keyof Config]: readonly (keyof Config & string)[] }, +>(config: Config): Tags { + const names = Object.keys(config) as TagNames[] + const values = Object.fromEntries(names.map((name) => [name, makeTag(name)])) as Tags["values"] + return { + values, + make: ((name: TagNames) => (input: DistributiveOmit, "tag">) => + make({ ...input, tag: values[name] })) as Tags["make"], + } +} + +export type Replacement = { + readonly source: Layer.Any + readonly replacement: Layer.Any +} + +type CheckReplacementErrors = [Exclude] extends [never] + ? unknown + : { readonly "New replacement errors": Exclude } + +export function replace( + source: Layer.Layer, + replacement: Layer.Layer, E2, never> & CheckReplacementErrors>, +): Replacement { + return { source, replacement } +} + +export * as LayerNode from "./layer-node" diff --git a/packages/core/src/effect/node-build.ts b/packages/core/src/effect/node-build.ts new file mode 100644 index 00000000000..c576da2daf0 --- /dev/null +++ b/packages/core/src/effect/node-build.ts @@ -0,0 +1,18 @@ +import { Layer } from "effect" +import { buildLocationServiceMap } from "../location-services" +import { LocationServiceMap } from "../location-service-map" +import { LayerNode, LayerNodeTree } from "./layer-node" +import { makeGlobalNode } from "./node" + +export function build(root: LayerNode.Node, replacements?: readonly LayerNode.Replacement[]) { + const replacementMap = new Map(replacements?.map((item) => [item.source, item.replacement])) + + const locationMap = buildLocationServiceMap(replacementMap) + const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] }) + + const app = LayerNodeTree.bind(root, LocationServiceMap.node, locationMapNode) + + return LayerNodeTree.compile(app, replacementMap) +} + +export * as NodeBuild from "./node-build" diff --git a/packages/core/src/effect/node.ts b/packages/core/src/effect/node.ts new file mode 100644 index 00000000000..4b316651c1c --- /dev/null +++ b/packages/core/src/effect/node.ts @@ -0,0 +1,14 @@ +import { LayerNode } from "./layer-node" + +export const tags = LayerNode.tags({ + location: ["global"], + global: [], +}) + +export type GlobalNode = LayerNode.Node +export type LocationNode = LayerNode.Node + +export const makeGlobalNode = tags.make("global") +export const makeLocationNode = tags.make("location") + +export * as Node from "./node" diff --git a/packages/core/src/effect/scoped-node.ts b/packages/core/src/effect/scoped-node.ts deleted file mode 100644 index 6583d2bb8a7..00000000000 --- a/packages/core/src/effect/scoped-node.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { LayerNode } from "./layer-node" - -export const tiers = LayerNode.tiers(["location", "global"]) - -export type GlobalNode = LayerNode.Node -export type LocationNode = LayerNode.Node - -export const makeGlobalNode = tiers.make("global") -export const makeLocationNode = tiers.make("location") - -export * as ScopedNode from "./scoped-node" diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 46cec83bebf..0c3e50ac08f 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -7,7 +7,7 @@ import { and, asc, eq, gt, inArray } from "drizzle-orm" import { Database } from "./database/database" import { EventSequenceTable, EventTable } from "./event/sql" import { Location } from "./location" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/node" import { isDeepStrictEqual } from "node:util" import { Durable } from "@opencode-ai/schema/durable-event-manifest" @@ -635,6 +635,6 @@ export const layerWith = (options?: LayerOptions) => ) export const layer = layerWith() -export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] }) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] }) export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) diff --git a/packages/core/src/file-mutation.ts b/packages/core/src/file-mutation.ts index a3c6f519a31..913829cdfc1 100644 --- a/packages/core/src/file-mutation.ts +++ b/packages/core/src/file-mutation.ts @@ -1,5 +1,6 @@ export * as FileMutation from "./file-mutation" +import { makeLocationNode } from "./effect/node" import { Context, Effect, Layer, Schema } from "effect" import { dirname } from "path" import { KeyedMutex } from "./effect/keyed-mutex" @@ -192,6 +193,8 @@ function sameBytes(left: Uint8Array, right: Uint8Array) { export const locationLayer = layer +export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] }) + /** * Deferred until the corresponding V2 integrations exist. */ diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index c02595463fc..34c71131e56 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -1,5 +1,6 @@ export * as FileSystem from "./filesystem" +import { makeLocationNode } from "./effect/node" import path from "path" import { Context, Effect, Layer, Schema } from "effect" import { FSUtil } from "./fs-util" @@ -113,3 +114,9 @@ const baseLayer = Layer.effect( export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.locationLayer), Layer.provide(FSUtil.defaultLayer)) export const locationLayer = layer + +export const node = makeLocationNode({ + service: Service, + layer: baseLayer, + deps: [FSUtil.node, Location.node, FileSystemSearch.node], +}) diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index db94711480a..0e29228af75 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -1,5 +1,6 @@ export * as FileSystemSearch from "./search" +import { makeLocationNode } from "../effect/node" import path from "path" import { Context, Effect, Layer, Scope } from "effect" import { Fff } from "#fff" @@ -229,6 +230,8 @@ export const fffLayer = Layer.effect( }), ) -export const locationLayer = Layer.unwrap( - Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)), -) +const layer = Layer.unwrap(Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer))) + +export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Location.node, Ripgrep.node] }) diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts index 5c8930898cd..92c330be768 100644 --- a/packages/core/src/filesystem/watcher.ts +++ b/packages/core/src/filesystem/watcher.ts @@ -3,6 +3,7 @@ export * as Watcher from "./watcher" // @ts-ignore import { createWrapper } from "@parcel/watcher/wrapper" import type ParcelWatcher from "@parcel/watcher" +import { makeLocationNode } from "../effect/node" import { Cause, Context, Effect, Layer } from "effect" import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher" import path from "path" @@ -133,3 +134,9 @@ export const layer = Layer.effect( ) export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer), Layer.provide(Git.defaultLayer)) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [FSUtil.node, Location.node, Config.node, Git.node, EventV2.node], +}) diff --git a/packages/core/src/fs-util.ts b/packages/core/src/fs-util.ts index da3d615b59f..8b62b5b6dfa 100644 --- a/packages/core/src/fs-util.ts +++ b/packages/core/src/fs-util.ts @@ -7,7 +7,7 @@ import { Context, Effect, FileSystem, Layer, Schema } from "effect" import type { PlatformError } from "effect/PlatformError" import { Glob } from "./util/glob" import { serviceUse } from "./effect/service-use" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/node" import { filesystem } from "./effect/layer-node-platform" export namespace FSUtil { @@ -201,7 +201,7 @@ export namespace FSUtil { ) export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer)) - export const node = LayerNode.make({ service: Service, layer: layer, deps: [filesystem] }) + export const node = makeGlobalNode({ service: Service, layer: layer, deps: [filesystem] }) // Pure helpers that don't need Effect (path manipulation, sync operations) export function mimeType(p: string): string { diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts index d6401fcdfe1..1c50744d18c 100644 --- a/packages/core/src/git.ts +++ b/packages/core/src/git.ts @@ -7,7 +7,7 @@ import { ChildProcess } from "effect/unstable/process" import { AbsolutePath, RelativePath } from "./schema" import { FSUtil } from "./fs-util" import { AppProcess } from "./process" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/node" import { File } from "./file" import { KeyedMutex } from "./effect/keyed-mutex" @@ -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({ service: Service, layer: layer, deps: [FSUtil.node, AppProcess.node] }) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, AppProcess.node] }) interface Result { readonly exitCode: number diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index eed28b94cf0..50d1eec8375 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -5,7 +5,7 @@ import os from "os" import { Context, Effect, Layer } from "effect" import { Flock } from "./util/flock" import { Flag } from "./flag/flag" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/node" const app = "opencode" const data = path.join(xdgData!, app) @@ -77,7 +77,7 @@ export const layer = Layer.effect( ) export const defaultLayer = layer -export const node = LayerNode.make({ service: Service, layer: layer, deps: [] }) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [] }) export const layerWith = (input: Partial) => Layer.effect( diff --git a/packages/core/src/image.ts b/packages/core/src/image.ts index 9c69d6aeb60..64c10e38ca2 100644 --- a/packages/core/src/image.ts +++ b/packages/core/src/image.ts @@ -1,5 +1,6 @@ export * as Image from "./image" +import { makeLocationNode } from "./effect/node" import { Context, Effect, Layer, Schema } from "effect" import { Config } from "./config" import { FileSystem } from "./filesystem" @@ -76,3 +77,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer)) + +export const node = makeLocationNode({ service: Service, layer, deps: [Config.node] }) diff --git a/packages/core/src/instruction-context.ts b/packages/core/src/instruction-context.ts index fdfe59aa0b4..3a376b8d5eb 100644 --- a/packages/core/src/instruction-context.ts +++ b/packages/core/src/instruction-context.ts @@ -9,6 +9,7 @@ import { Location } from "./location" import { AbsolutePath } from "./schema" import { SystemContext } from "./system-context/index" import { SystemContextRegistry } from "./system-context/registry" +import { makeLocationNode } from "./effect/node" class File extends Schema.Class("InstructionContext.File")({ path: AbsolutePath, @@ -87,6 +88,12 @@ export const layer = Layer.effectDiscard( }), ) +export const node = makeLocationNode({ + name: "instruction-context", + layer, + deps: [FSUtil.node, Global.node, Location.node, SystemContextRegistry.node], +}) + function render(files: ReadonlyArray) { return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n") } diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index af096df2fd5..1d3947ea444 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -1,5 +1,6 @@ export * as Integration from "./integration" +import { makeLocationNode } from "./effect/node" import { Cause, Clock, @@ -515,3 +516,5 @@ export const locationLayer = Layer.effect( }) }), ) + +export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [Credential.node, EventV2.node] }) diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts deleted file mode 100644 index bfbf5c6eede..00000000000 --- a/packages/core/src/location-layer.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { Effect, Layer, LayerMap } from "effect" -import { Location } from "./location" -import { Policy } from "./policy" -import { Config } from "./config" -import { PluginV2 } from "./plugin" -import { Catalog } from "./catalog" -import { Integration } from "./integration" -import { CommandV2 } from "./command" -import { AgentV2 } from "./agent" -import { PluginInternal } from "./plugin/internal" -import { Project } from "./project" -import { ProjectCopy } from "./project/copy" -import { ProjectDirectories } from "./project/directories" -import { EventV2 } from "./event" -import { Credential } from "./credential" -import { Npm } from "./npm" -import { ModelsDev } from "./models-dev" -import { FSUtil } from "./fs-util" -import { Git } from "./git" -import { Global } from "./global" -import { Database } from "./database/database" -import { PermissionV2 } from "./permission" -import { PermissionSaved } from "./permission/saved" -import { FileSystem } from "./filesystem" -import { Ripgrep } from "./ripgrep" -import { Watcher } from "./filesystem/watcher" -import { LocationMutation } from "./location-mutation" -import { FileMutation } from "./file-mutation" -import { Reference } from "./reference" -import { ReferenceGuidance } from "./reference/guidance" -import { RepositoryCache } from "./repository-cache" -import { Pty } from "./pty" -import { SkillV2 } from "./skill" -import { SkillGuidance } from "./skill/guidance" -import { BuiltInTools } from "./tool/builtins" -import { Image } from "./image" -import { ToolRegistry } from "./tool/registry" -import { ApplicationTools } from "./tool/application-tools" -import { ToolOutputStore } from "./tool-output-store" -import { AppProcess } from "./process" -import { SessionStore } from "./session/store" -import { SessionTodo } from "./session/todo" -import { QuestionV2 } from "./question" -import { LLMClient } from "@opencode-ai/llm" -import { RequestExecutor } from "@opencode-ai/llm/route" -import * as SessionRunnerLLM from "./session/runner/llm" -import { SessionRunnerModel } from "./session/runner/model" -import { SystemContextBuiltIns } from "./system-context/builtins" -import { FetchHttpClient } from "effect/unstable/http" -import { Snapshot } from "./snapshot" - -export class LocationServiceMap extends LayerMap.Service()("@opencode/example/LocationServiceMap", { - lookup: (ref: Location.Ref) => { - const boot = Layer.effectDiscard( - Effect.logInfo("booting location services", { directory: ref.directory, workspaceID: ref.workspaceID }), - ) - const location = Location.layer(ref) - const systemContext = SystemContextBuiltIns.locationLayer - const base = Layer.mergeAll( - location, - Policy.locationLayer, - Config.locationLayer, - Reference.locationLayer, - PluginV2.locationLayer, - Catalog.locationLayer, - Integration.locationLayer, - CommandV2.locationLayer, - AgentV2.locationLayer, - PluginInternal.locationLayer, - ProjectCopy.locationLayer, - FileSystem.locationLayer, - Watcher.locationLayer, - Pty.locationLayer, - SkillV2.locationLayer, - systemContext, - LocationMutation.locationLayer.pipe(Layer.orDie), - ).pipe(Layer.provideMerge(location)) - const resources = ToolOutputStore.layer.pipe(Layer.provide(base)) - const permissionsAndTools = ToolRegistry.layer.pipe( - Layer.provideMerge(PermissionV2.locationLayer), - Layer.provide(resources), - Layer.provide(base), - ) - const services = Layer.mergeAll(base, resources, permissionsAndTools) - const image = Image.layer.pipe(Layer.provide(services)) - const mutation = FileMutation.locationLayer.pipe(Layer.provide(services)) - const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services)) - const referenceGuidance = ReferenceGuidance.locationLayer.pipe(Layer.provide(services)) - const todos = SessionTodo.layer.pipe(Layer.provide(services)) - const questions = QuestionV2.locationLayer.pipe(Layer.provide(services)) - const builtInTools = BuiltInTools.locationLayer.pipe( - Layer.provide(services), - Layer.provide(mutation), - Layer.provide(resources), - Layer.provide(todos), - Layer.provide(questions), - Layer.provide(image), - ) - const model = SessionRunnerModel.locationLayer.pipe(Layer.provide(services)) - const snapshot = Snapshot.locationLayer.pipe(Layer.provide(services)) - const runner = SessionRunnerLLM.defaultLayer.pipe( - Layer.provide(services), - Layer.provide(model), - Layer.provide(skillGuidance), - Layer.provide(referenceGuidance), - Layer.provide(snapshot), - ) - - // Kick off a background project copy refresh to update locations now that we - // have a location - const projectCopyRefresh = Layer.effectDiscard(ProjectCopy.refreshAfterBoot).pipe(Layer.provide(services)) - - return Layer.mergeAll( - boot, - services, - image, - mutation, - resources, - todos, - questions, - model, - snapshot, - runner, - builtInTools, - referenceGuidance, - projectCopyRefresh, - ).pipe(Layer.fresh) - }, - idleTimeToLive: "60 minutes", - dependencies: [ - Project.defaultLayer, - EventV2.defaultLayer, - Credential.defaultLayer, - Npm.defaultLayer, - ModelsDev.defaultLayer, - FSUtil.defaultLayer, - Git.defaultLayer, - AppProcess.defaultLayer, - Global.defaultLayer, - Ripgrep.defaultLayer, - Database.defaultLayer, - ProjectDirectories.defaultLayer, - SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)), - PermissionSaved.defaultLayer, - RepositoryCache.defaultLayer, - LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer)), - FetchHttpClient.layer, - ToolOutputStore.defaultCleanupLayer, - ApplicationTools.layer, - ], -}) {} diff --git a/packages/core/src/location-mutation.ts b/packages/core/src/location-mutation.ts index a620c66e31f..a697fbbf821 100644 --- a/packages/core/src/location-mutation.ts +++ b/packages/core/src/location-mutation.ts @@ -1,5 +1,6 @@ export * as LocationMutation from "./location-mutation" +import { makeLocationNode } from "./effect/node" import path from "path" import { Context, Effect, Layer, Schema } from "effect" import { FSUtil } from "./fs-util" @@ -153,3 +154,9 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ + service: Service, + layer: layer.pipe(Layer.orDie), + deps: [FSUtil.node, Location.node], +}) diff --git a/packages/core/src/location-service-map.ts b/packages/core/src/location-service-map.ts new file mode 100644 index 00000000000..fa261da5194 --- /dev/null +++ b/packages/core/src/location-service-map.ts @@ -0,0 +1,18 @@ +import { Context, Effect, Layer, LayerMap } from "effect" +import { LayerNode } from "./effect/layer-node" +import { Node } from "./effect/node" +import { Location } from "./location" +import type { LocationError, LocationServices } from "./location-services" + +export class Service extends Context.Service< + Service, + LayerMap.LayerMap +>()("@opencode/example/LocationServiceMap") { + static get(ref: Location.Ref) { + return Layer.unwrap(Effect.map(Service, (locations) => locations.get(ref))) + } +} + +export const node = LayerNode.unbound(Service, Node.tags.values.global) + +export * as LocationServiceMap from "./location-service-map" diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts new file mode 100644 index 00000000000..ba06622d224 --- /dev/null +++ b/packages/core/src/location-services.ts @@ -0,0 +1,112 @@ +import { Effect, Layer, LayerMap } from "effect" +import { AgentV2 } from "./agent" +import { AISDK } from "./aisdk" +import { Catalog } from "./catalog" +import { CommandV2 } from "./command" +import { Config } from "./config" +import { LayerNode, LayerNodeTree } from "./effect/layer-node" +import { Node } from "./effect/node" +import { FileMutation } from "./file-mutation" +import { FileSystem } from "./filesystem" +import { FileSystemSearch } from "./filesystem/search" +import { Watcher } from "./filesystem/watcher" +import { Image } from "./image" +import { Integration } from "./integration" +import { Location } from "./location" +import { LocationMutation } from "./location-mutation" +import { LocationServiceMap } from "./location-service-map" +import { PermissionV2 } from "./permission" +import { PluginV2 } from "./plugin" +import { PluginInternal } from "./plugin/internal" +import { Policy } from "./policy" +import { ProjectCopy } from "./project/copy" +import { Pty } from "./pty" +import { QuestionV2 } from "./question" +import { Reference } from "./reference" +import { ReferenceGuidance } from "./reference/guidance" +import * as SessionRunnerLLM from "./session/runner/llm" +import { SessionRunnerModel } from "./session/runner/model" +import { SessionTodo } from "./session/todo" +import { SkillV2 } from "./skill" +import { SkillGuidance } from "./skill/guidance" +import { Snapshot } from "./snapshot" +import { SystemContextBuiltIns } from "./system-context/builtins" +import { SystemContextRegistry } from "./system-context/registry" +import { BuiltInTools } from "./tool/builtins" +import { ReadToolFileSystem } from "./tool/read-filesystem" +import { ToolRegistry } from "./tool/registry" +import { ToolOutputStore } from "./tool-output-store" + +export { LocationServiceMap } from "./location-service-map" + +export const locationServices = LayerNode.group([ + Location.node, + Policy.node, + Config.node, + AgentV2.node, + CommandV2.node, + Reference.node, + Integration.node, + Catalog.node, + AISDK.node, + PluginV2.node, + PluginInternal.node, + ProjectCopy.node, + ProjectCopy.refreshNode, + FileSystemSearch.node, + FileSystem.node, + Watcher.node, + Pty.node, + SkillV2.node, + SystemContextRegistry.node, + SystemContextBuiltIns.node, + LocationMutation.node, + FileMutation.node, + PermissionV2.node, + ToolOutputStore.node, + ToolRegistry.node, + ToolRegistry.toolsNode, + Image.node, + SkillGuidance.node, + ReferenceGuidance.node, + SessionTodo.node, + QuestionV2.node, + ReadToolFileSystem.node, + BuiltInTools.node, + SessionRunnerModel.node, + Snapshot.node, + SessionRunnerLLM.node, +]) + +export type LocationServices = LayerNode.Output +export type LocationError = LayerNode.Error + +export function buildLocationServiceMap( + replacements?: ReadonlyMap, +): Layer.Layer { + return Layer.effect( + LocationServiceMap.Service, + LayerMap.make( + (ref: Location.Ref) => { + const location = LayerNodeTree.hoist( + LayerNodeTree.bind(locationServices, Location.node, Location.boundNode(ref)), + Node.tags.values.global, + ) + return LayerNodeTree.compile(location.node, replacements).pipe( + Layer.fresh, + Layer.tap(() => + Effect.logInfo("booting location services", { + directory: ref.directory, + workspaceID: ref.workspaceID, + }), + ), + Layer.provide(LayerNodeTree.compile(location.hoisted, replacements)), + ) + }, + { idleTimeToLive: "60 minutes" }, + ), + ) +} + +// This is temporary for backwards compatibility +export const locationServiceMapLayer = buildLocationServiceMap() diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index 21f2150c5cd..8a7f03ad885 100644 --- a/packages/core/src/location.ts +++ b/packages/core/src/location.ts @@ -1,6 +1,8 @@ import { Context, Effect, Layer } from "effect" import { Info, Ref, response } from "@opencode-ai/schema/location" import { Project } from "./project" +import { LayerNode } from "./effect/layer-node" +import { makeLocationNode, tags } from "./effect/node" export * as Location from "./location" @@ -12,6 +14,8 @@ export interface Interface extends Info { export class Service extends Context.Service()("@opencode/Location") {} +export const node = LayerNode.unbound(Service, tags.values.location) + export const layer = (ref: Ref) => Layer.effect( Service, @@ -26,3 +30,10 @@ export const layer = (ref: Ref) => }) }), ) + +export const boundNode = (ref: Ref) => + makeLocationNode({ + service: Service, + layer: layer(ref), + deps: [Project.node], + }) diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 6d4dc09a79e..e1d6011b5b3 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -9,7 +9,7 @@ import { Hash } from "./util/hash" import { FSUtil } from "./fs-util" import { InstallationChannel, InstallationVersion } from "./installation/version" import { EventV2 } from "./event" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/node" import { httpClient } from "./effect/layer-node-platform" export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"]) @@ -244,6 +244,6 @@ export const defaultLayer = layer.pipe( Layer.provide(FSUtil.defaultLayer), Layer.provide(EventV2.defaultLayer), ) -export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] }) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] }) export * as ModelsDev from "./models-dev" diff --git a/packages/core/src/npm.ts b/packages/core/src/npm.ts index 0a13e0f7c35..49472f93d41 100644 --- a/packages/core/src/npm.ts +++ b/packages/core/src/npm.ts @@ -7,7 +7,7 @@ import { NodeFileSystem } from "@effect/platform-node" import { FSUtil } from "./fs-util" import { Global } from "./global" import { EffectFlock } from "./util/effect-flock" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/node" import { filesystem } from "./effect/layer-node-platform" import { makeRuntime } from "./effect/runtime" import { NpmConfig } from "./npm-config" @@ -253,7 +253,7 @@ export const defaultLayer = layer.pipe( Layer.provide(Global.layer), Layer.provide(NodeFileSystem.layer), ) -export const node = LayerNode.make({ +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, Global.node, filesystem, EffectFlock.node], diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index 0ee64a07d7f..805ecf01bb9 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -1,5 +1,6 @@ export * as PermissionV2 from "./permission" +import { makeLocationNode } from "./effect/node" import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect" import { Permission } from "@opencode-ai/schema/permission" import { EventV2 } from "./event" @@ -300,3 +301,9 @@ export const layer = Layer.effect( ) export const locationLayer = layer.pipe(Layer.provideMerge(AgentV2.locationLayer)) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [EventV2.node, Location.node, AgentV2.node, SessionStore.node, PermissionSaved.node], +}) diff --git a/packages/core/src/permission/saved.ts b/packages/core/src/permission/saved.ts index dd9fa5a7503..0814e2225dc 100644 --- a/packages/core/src/permission/saved.ts +++ b/packages/core/src/permission/saved.ts @@ -3,6 +3,7 @@ export * as PermissionSaved from "./saved" import { eq } from "drizzle-orm" import { Context, Effect, Layer, Schema } from "effect" import { Database } from "../database/database" +import { makeGlobalNode } from "../effect/node" import { ProjectV2 } from "../project" import { PermissionTable } from "./sql" import { PermissionSaved } from "@opencode-ai/schema/permission-saved" @@ -76,3 +77,5 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) + +export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] }) diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 95b6357571e..f274b8e08f7 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -1,5 +1,6 @@ export * as PluginV2 from "./plugin" +import { makeLocationNode } from "./effect/node" import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect" import type { Plugin as PluginRuntime } from "@opencode-ai/plugin/v2/effect" import { Plugin } from "@opencode-ai/schema/plugin" @@ -150,3 +151,18 @@ export const locationLayer = layer.pipe( Layer.provideMerge(Reference.locationLayer), Layer.provideMerge(SkillV2.locationLayer), ) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [ + EventV2.node, + AgentV2.node, + AISDK.node, + Catalog.node, + CommandV2.node, + Integration.node, + Reference.node, + SkillV2.node, + ], +}) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 51c6e279b78..461d2297f1d 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -1,5 +1,7 @@ export * as PluginInternal from "./internal" +import { makeLocationNode } from "../effect/node" +import { httpClient } from "../effect/layer-node-platform" import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import { Effect, Layer, Scope } from "effect" import { AgentV2 } from "../agent" @@ -57,7 +59,7 @@ export function define(plugin: Plugin) { return plugin } -export const locationLayer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const catalog = yield* Catalog.Service const commands = yield* CommandV2.Service @@ -117,9 +119,34 @@ export const locationLayer = Layer.effectDiscard( yield* add(VariantPlugin.Plugin) }).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true })) }), -).pipe( +) + +export const locationLayer = layer.pipe( Layer.provideMerge(PluginV2.locationLayer), Layer.provideMerge(Config.locationLayer), Layer.provideMerge(FileSystem.locationLayer), Layer.provideMerge(FetchHttpClient.layer), ) + +export const node = makeLocationNode({ + name: "plugin-internal", + layer, + deps: [ + Catalog.node, + CommandV2.node, + PluginV2.node, + Integration.node, + AgentV2.node, + Config.node, + Location.node, + ModelsDev.node, + Npm.node, + EventV2.node, + FSUtil.node, + FileSystem.node, + Global.node, + httpClient, + SkillV2.node, + Reference.node, + ], +}) diff --git a/packages/core/src/policy.ts b/packages/core/src/policy.ts index 9b7438f4ffd..3b27027f760 100644 --- a/packages/core/src/policy.ts +++ b/packages/core/src/policy.ts @@ -1,5 +1,6 @@ export * as Policy from "./policy" +import { makeLocationNode } from "./effect/node" import { Context, Effect as EffectRuntime, Layer, Schema } from "effect" import { Wildcard } from "./util/wildcard" import { Location } from "./location" @@ -44,3 +45,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] }) diff --git a/packages/core/src/process.ts b/packages/core/src/process.ts index f8d4ef03312..ef7b6f78ab6 100644 --- a/packages/core/src/process.ts +++ b/packages/core/src/process.ts @@ -3,7 +3,7 @@ import type { PlatformError } from "effect/PlatformError" import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { CrossSpawnSpawner } from "./cross-spawn-spawner" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/node" export class AppProcessError extends Schema.TaggedErrorClass()("AppProcessError", { command: Schema.String, @@ -238,6 +238,6 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer)) -export const node = LayerNode.make({ service: Service, layer: layer, deps: [CrossSpawnSpawner.node] }) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [CrossSpawnSpawner.node] }) export * as AppProcess from "./process" diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index c504c309be7..4c4f012ad39 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -6,7 +6,7 @@ import path from "path" import { AbsolutePath } from "./schema" import { FSUtil } from "./fs-util" import { Git } from "./git" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/node" import { Hash } from "./util/hash" import { ProjectDirectories } from "./project/directories" import { ProjectSchema } from "./project/schema" @@ -134,7 +134,7 @@ export const defaultLayer = layer.pipe( Layer.provide(Git.defaultLayer), Layer.provideMerge(ProjectDirectories.defaultLayer), ) -export const node = LayerNode.make({ +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, Git.node, ProjectDirectories.node], diff --git a/packages/core/src/project/copy.ts b/packages/core/src/project/copy.ts index d144b15ece5..433f667a723 100644 --- a/packages/core/src/project/copy.ts +++ b/packages/core/src/project/copy.ts @@ -5,7 +5,7 @@ import path from "path" import { AbsolutePath } from "../schema" import { FSUtil } from "../fs-util" import { Git } from "../git" -import { LayerNode } from "../effect/layer-node" +import { makeLocationNode } from "../effect/node" import { Project } from "../project" import { ProjectDirectories } from "./directories" import { makeGitWorktreeStrategy } from "./copy-strategies" @@ -279,8 +279,14 @@ export const layer = Layer.effect( ) export const locationLayer = layer -export const node = LayerNode.make({ +export const node = makeLocationNode({ service: Service, layer: layer, deps: [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node], }) + +export const refreshNode = makeLocationNode({ + name: "project-copy-refresh", + layer: Layer.effectDiscard(refreshAfterBoot), + deps: [node, Location.node], +}) diff --git a/packages/core/src/project/directories.ts b/packages/core/src/project/directories.ts index 54c31628bf6..525fea413b5 100644 --- a/packages/core/src/project/directories.ts +++ b/packages/core/src/project/directories.ts @@ -3,7 +3,7 @@ export * as ProjectDirectories from "./directories" import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm" import { Context, Effect, Layer, Schema } from "effect" import { Database } from "../database/database" -import { LayerNode } from "../effect/layer-node" +import { makeGlobalNode } from "../effect/node" import { AbsolutePath, optional } from "../schema" import { ProjectSchema } from "./schema" import { ProjectDirectoryTable } from "./sql" @@ -156,4 +156,4 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) -export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] }) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] }) diff --git a/packages/core/src/pty.ts b/packages/core/src/pty.ts index 9c5d2455a6e..12cad6aa9c4 100644 --- a/packages/core/src/pty.ts +++ b/packages/core/src/pty.ts @@ -1,5 +1,6 @@ export * as Pty from "./pty" +import { makeLocationNode } from "./effect/node" import type { Disp, Proc } from "#pty" import { Context, Effect, Layer, Schema, Types } from "effect" import { Pty } from "@opencode-ai/schema/pty" @@ -313,3 +314,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer)) + +export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Location.node, Config.node] }) diff --git a/packages/core/src/pty/ticket.ts b/packages/core/src/pty/ticket.ts index aee1debc787..da2a76a77c6 100644 --- a/packages/core/src/pty/ticket.ts +++ b/packages/core/src/pty/ticket.ts @@ -4,7 +4,7 @@ import { WorkspaceV2 } from "../workspace" import { PtyTicket } from "@opencode-ai/schema/pty-ticket" import { PtyID } from "./schema" import { Cache, Context, Duration, Effect, Layer } from "effect" -import { LayerNode } from "../effect/layer-node" +import { makeGlobalNode } from "../effect/node" const DEFAULT_TTL = Duration.seconds(60) const CAPACITY = 10_000 @@ -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({ service: Service, layer: layer, deps: [] }) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [] }) diff --git a/packages/core/src/question.ts b/packages/core/src/question.ts index 2f4942af1b0..5d1bff9c81f 100644 --- a/packages/core/src/question.ts +++ b/packages/core/src/question.ts @@ -1,5 +1,6 @@ export * as QuestionV2 from "./question" +import { makeLocationNode } from "./effect/node" import { Context, Deferred, Effect, Layer, Schema } from "effect" import { Question } from "@opencode-ai/schema/question" import { EventV2 } from "./event" @@ -148,3 +149,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] }) diff --git a/packages/core/src/reference.ts b/packages/core/src/reference.ts index 36065ff9209..145254137d0 100644 --- a/packages/core/src/reference.ts +++ b/packages/core/src/reference.ts @@ -1,5 +1,6 @@ export * as Reference from "./reference" +import { makeLocationNode } from "./effect/node" import { Context, Effect, Layer, Scope, Types } from "effect" import { Reference } from "@opencode-ai/schema/reference" import { Global } from "./global" @@ -120,3 +121,9 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [Global.node, EventV2.node, RepositoryCache.node], +}) diff --git a/packages/core/src/reference/guidance.ts b/packages/core/src/reference/guidance.ts index fa3423c58ac..528c8c790ef 100644 --- a/packages/core/src/reference/guidance.ts +++ b/packages/core/src/reference/guidance.ts @@ -1,5 +1,6 @@ export * as ReferenceGuidance from "./guidance" +import { makeLocationNode } from "../effect/node" import { Context, Effect, Layer, Schema } from "effect" import { Reference } from "../reference" import { SystemContext } from "../system-context/index" @@ -64,3 +65,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [Reference.node] }) diff --git a/packages/core/src/repository-cache.ts b/packages/core/src/repository-cache.ts index 410b82fea54..d185dbd12f3 100644 --- a/packages/core/src/repository-cache.ts +++ b/packages/core/src/repository-cache.ts @@ -5,6 +5,7 @@ import { Git } from "./git" import { Global } from "./global" import { Repository } from "./repository" import { AbsolutePath } from "./schema" +import { makeGlobalNode } from "./effect/node" import { EffectFlock } from "./util/effect-flock" export type Result = { @@ -229,6 +230,12 @@ export const defaultLayer: Layer.Layer = layer.pipe( Layer.provide(Global.defaultLayer), ) +export const node = makeGlobalNode({ + service: Service, + layer, + deps: [EffectFlock.node, FSUtil.node, Git.node, Global.node], +}) + function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) { if (!input.reuse) return "cloned" as const if (input.branchMatches === false || input.refresh) return "refreshed" as const diff --git a/packages/core/src/ripgrep.ts b/packages/core/src/ripgrep.ts index c240c6763fa..61a4950f60e 100644 --- a/packages/core/src/ripgrep.ts +++ b/packages/core/src/ripgrep.ts @@ -3,7 +3,7 @@ export * as Ripgrep from "./ripgrep" import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" import { Entry, Match } from "@opencode-ai/schema/filesystem" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/node" import { AppProcess, collectStream, waitForAbort } from "./process" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema" import { RipgrepBinary } from "./ripgrep/binary" @@ -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({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] }) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] }) diff --git a/packages/core/src/ripgrep/binary.ts b/packages/core/src/ripgrep/binary.ts index 18cd6a62c28..805d8895714 100644 --- a/packages/core/src/ripgrep/binary.ts +++ b/packages/core/src/ripgrep/binary.ts @@ -4,7 +4,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/ import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { CrossSpawnSpawner } from "../cross-spawn-spawner" -import { LayerNode } from "../effect/layer-node" +import { makeGlobalNode } from "../effect/node" import { httpClient } from "../effect/layer-node-platform" import { FSUtil } from "../fs-util" import { Global } from "../global" @@ -130,7 +130,7 @@ export namespace RipgrepBinary { Layer.provide(CrossSpawnSpawner.defaultLayer), ) - export const node = LayerNode.make({ + export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, httpClient, CrossSpawnSpawner.node], diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 8ccb9fa2930..35f9a265afd 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -27,6 +27,8 @@ import { fromRow } from "./session/info" import { SessionRunner } from "./session/runner/index" import { SessionStore } from "./session/store" import { SessionExecution } from "./session/execution" +import { makeGlobalNode } from "./effect/node" +import { LocationServiceMap } from "./location-service-map" import { MessageDecodeError } from "./session/error" import { SessionEvent } from "./session/event" import { SessionInput } from "./session/input" @@ -179,19 +181,16 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/Session") {} -export const layer = Layer.unwrap( - Effect.promise(() => import("./location-layer")).pipe( - Effect.map(({ LocationServiceMap }) => - Layer.effect( - Service, - Effect.gen(function* () { +export const layer = Layer.effect( + Service, + Effect.gen(function* () { const database = yield* Database.Service const db = database.db const events = yield* EventV2.Service const projects = yield* ProjectV2.Service const execution = yield* SessionExecution.Service const store = yield* SessionStore.Service - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) const isDurableSessionEvent = Schema.is(SessionEvent.Durable) const decode = (row: typeof SessionMessageTable.$inferSelect) => @@ -451,17 +450,11 @@ export const layer = Layer.unwrap( }, }) - return result - }), - ), - ), - ), + return result + }), ) export const defaultLayer = layer.pipe( - Layer.provide( - Layer.unwrap(Effect.promise(() => import("./location-layer")).pipe(Effect.map((m) => m.LocationServiceMap.layer))), - ), Layer.provide(SessionStore.defaultLayer), Layer.provide(SessionProjector.defaultLayer), Layer.provide(EventV2.defaultLayer), @@ -483,3 +476,17 @@ const resolvePrompt = (input: PromptInput.Prompt) => } }), }) + +export const node = makeGlobalNode({ + service: Service, + layer: layer.pipe(Layer.orDie), + deps: [ + Database.node, + EventV2.node, + ProjectV2.node, + SessionExecution.node, + SessionStore.node, + LocationServiceMap.node, + SessionProjector.node, + ], +}) diff --git a/packages/core/src/session/execution.ts b/packages/core/src/session/execution.ts index f92929e6ee9..a2b4db4dfd8 100644 --- a/packages/core/src/session/execution.ts +++ b/packages/core/src/session/execution.ts @@ -1,6 +1,8 @@ export * as SessionExecution from "./execution" import { Context, Effect, Layer } from "effect" +import { LayerNode } from "../effect/layer-node" +import { Node } from "../effect/node" import { SessionRunner } from "./runner/index" import { SessionSchema } from "./schema" @@ -18,6 +20,8 @@ export interface Interface { /** Routes execution from a Session ID to the runner owned by that Session's Location. */ export class Service extends Context.Service()("@opencode/v2/SessionExecution") {} +export const node = LayerNode.unbound(Service, Node.tags.values.global) + /** Low-level compatibility layer for callers that only need durable Session recording. */ export const noopLayer = Layer.succeed( Service, diff --git a/packages/core/src/session/execution/local.ts b/packages/core/src/session/execution/local.ts index bd1be745393..fc157eeea58 100644 --- a/packages/core/src/session/execution/local.ts +++ b/packages/core/src/session/execution/local.ts @@ -1,5 +1,6 @@ import { Cause, Effect, Layer } from "effect" -import { LocationServiceMap } from "../../location-layer" +import { LocationServiceMap } from "../../location-service-map" +import { makeGlobalNode } from "../../effect/node" import { SessionRunCoordinator } from "../run-coordinator" import { SessionRunner } from "../runner" import { SessionSchema } from "../schema" @@ -11,7 +12,7 @@ export const layer = Layer.effect( SessionExecution.Service, Effect.gen(function* () { const store = yield* SessionStore.Service - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service const coordinator = yield* SessionRunCoordinator.make({ drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) { const session = yield* store.get(sessionID) @@ -37,3 +38,11 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe(Layer.provide(SessionStore.defaultLayer)) + +export const node = makeGlobalNode({ + service: SessionExecution.Service, + layer, + deps: [SessionStore.node, LocationServiceMap.node], +}) + +export * as SessionExecutionLocal from "./local" diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 4d8b9a05b4c..ac79d598c73 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -4,7 +4,7 @@ import { and, desc, eq, gt, or, sql } from "drizzle-orm" import { DateTime, Effect, Layer, Schema } from "effect" import { Database } from "../database/database" import { EventV2 } from "../event" -import { LayerNode } from "../effect/layer-node" +import { makeGlobalNode } from "../effect/node" import { SessionEvent } from "./event" import { SessionV1 } from "../v1/session" import { WorkspaceTable } from "../control-plane/workspace.sql" @@ -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({ name: "session-projector", layer, deps: [EventV2.node, Database.node] }) +export const node = makeGlobalNode({ name: "session-projector", layer, deps: [EventV2.node, Database.node] }) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 9e29ae71ee9..3e22bfe3c67 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -36,6 +36,8 @@ import { createLLMEventPublisher } from "./publish-llm-event" import { toLLMMessages } from "./to-llm-message" import { MAX_STEPS_PROMPT } from "./max-steps" import { Snapshot } from "../../snapshot" +import { makeLocationNode } from "../../effect/node" +import { llmClient } from "../../effect/layer-node-platform" /** * Runs one durable coding-agent Session until it settles. @@ -404,3 +406,23 @@ export const layer = Layer.effect( ) export const defaultLayer = layer + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [ + EventV2.node, + llmClient, + AgentV2.node, + ToolRegistry.node, + SessionRunnerModel.node, + SessionStore.node, + Location.node, + SystemContextRegistry.node, + SkillGuidance.node, + ReferenceGuidance.node, + Config.node, + Snapshot.node, + Database.node, + ], +}) diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index b864df8ba38..ffe6deea01a 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -1,5 +1,6 @@ export * as SessionRunnerModel from "./model" +import { makeLocationNode } from "../../effect/node" import { type Model } from "@opencode-ai/llm" import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages" import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat" @@ -213,3 +214,5 @@ export const locationLayer = Layer.effect( }) }), ) + +export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [Catalog.node, Integration.node] }) diff --git a/packages/core/src/session/store.ts b/packages/core/src/session/store.ts index 1cff2313062..cf4b68ad058 100644 --- a/packages/core/src/session/store.ts +++ b/packages/core/src/session/store.ts @@ -3,6 +3,7 @@ export * as SessionStore from "./store" import { eq } from "drizzle-orm" import { Context, Effect, Layer, Schema } from "effect" import { Database } from "../database/database" +import { makeGlobalNode } from "../effect/node" import { SessionHistory } from "./history" import { MessageDecodeError } from "./error" import { SessionMessage } from "./message" @@ -60,3 +61,5 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) + +export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] }) diff --git a/packages/core/src/session/todo.ts b/packages/core/src/session/todo.ts index c5ad70afadd..fe76807d231 100644 --- a/packages/core/src/session/todo.ts +++ b/packages/core/src/session/todo.ts @@ -4,6 +4,7 @@ import { asc, eq } from "drizzle-orm" import { Context, Effect, Layer } from "effect" import { SessionTodo } from "@opencode-ai/schema/session-todo" import { Database } from "../database/database" +import { makeLocationNode } from "../effect/node" import { EventV2 } from "../event" import { SessionSchema } from "./schema" import { TodoTable } from "./sql" @@ -75,3 +76,5 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer)) + +export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Database.node] }) diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts index 6c1d37f3051..d6790396f33 100644 --- a/packages/core/src/skill.ts +++ b/packages/core/src/skill.ts @@ -1,5 +1,6 @@ export * as SkillV2 from "./skill" +import { makeLocationNode } from "./effect/node" import path from "path" import { Context, Effect, Layer, Schema, Types } from "effect" import { Skill } from "@opencode-ai/schema/skill" @@ -129,3 +130,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer.pipe(Layer.provide(SkillDiscovery.defaultLayer)) + +export const node = makeLocationNode({ service: Service, layer, deps: [SkillDiscovery.node, FSUtil.node] }) diff --git a/packages/core/src/skill/discovery.ts b/packages/core/src/skill/discovery.ts index 8c885eef1f3..1d3758cf8d4 100644 --- a/packages/core/src/skill/discovery.ts +++ b/packages/core/src/skill/discovery.ts @@ -5,6 +5,8 @@ import { Context, Effect, Layer, Schedule, Schema } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { FSUtil } from "../fs-util" import { Global } from "../global" +import { makeGlobalNode } from "../effect/node" +import { httpClient } from "../effect/layer-node-platform" import { AbsolutePath } from "../schema" const skillConcurrency = 4 @@ -213,3 +215,5 @@ export const defaultLayer = layer.pipe( Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.defaultLayer), ) + +export const node = makeGlobalNode({ service: Service, layer, deps: [httpClient, FSUtil.node, Global.node] }) diff --git a/packages/core/src/skill/guidance.ts b/packages/core/src/skill/guidance.ts index 81a33f01645..1c73b7ddb8e 100644 --- a/packages/core/src/skill/guidance.ts +++ b/packages/core/src/skill/guidance.ts @@ -1,5 +1,6 @@ export * as SkillGuidance from "./guidance" +import { makeLocationNode } from "../effect/node" import { Context, Effect, Layer, Schema } from "effect" import { AgentV2 } from "../agent" import { PermissionV2 } from "../permission" @@ -71,3 +72,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [SkillV2.node] }) diff --git a/packages/core/src/snapshot.ts b/packages/core/src/snapshot.ts index 631bca2a231..bf0d3e60d89 100644 --- a/packages/core/src/snapshot.ts +++ b/packages/core/src/snapshot.ts @@ -1,5 +1,6 @@ export * as Snapshot from "./snapshot" +import { makeLocationNode } from "./effect/node" import path from "path" import { Context, Effect, Layer, Schema } from "effect" import { Config } from "./config" @@ -228,6 +229,12 @@ export const layer = Layer.effect( export const locationLayer = layer.pipe(Layer.provideMerge(Config.locationLayer)) +export const node = makeLocationNode({ + service: Service, + layer, + deps: [Config.node, FSUtil.node, Git.node, Global.node, Location.node], +}) + export const noopLayer = Layer.succeed( Service, Service.of({ diff --git a/packages/core/src/system-context/builtins.ts b/packages/core/src/system-context/builtins.ts index 42cba27b922..23f97770559 100644 --- a/packages/core/src/system-context/builtins.ts +++ b/packages/core/src/system-context/builtins.ts @@ -1,10 +1,13 @@ export * as SystemContextBuiltIns from "./builtins" +import { makeLocationNode } from "../effect/node" import { DateTime, Effect, Layer, Schema } from "effect" import { Location } from "../location" import { SystemContext } from "./index" import { InstructionContext } from "../instruction-context" import { SystemContextRegistry } from "./registry" +import { FSUtil } from "../fs-util" +import { Global } from "../global" const builtIns = Layer.effectDiscard( Effect.gen(function* () { @@ -45,3 +48,9 @@ export const layer = Layer.mergeAll(builtIns, InstructionContext.layer).pipe( ) export const locationLayer = layer + +export const node = makeLocationNode({ + name: "system-context-builtins", + layer, + deps: [Location.node, SystemContextRegistry.node, InstructionContext.node, FSUtil.node, Global.node], +}) diff --git a/packages/core/src/system-context/registry.ts b/packages/core/src/system-context/registry.ts index a2a7ca7e629..87ed1573bbf 100644 --- a/packages/core/src/system-context/registry.ts +++ b/packages/core/src/system-context/registry.ts @@ -2,6 +2,7 @@ export * as SystemContextRegistry from "./registry" import { Context, Effect, Layer, Ref, Scope } from "effect" import { SystemContext } from "./index" +import { makeLocationNode } from "../effect/node" export interface Entry { readonly key: SystemContext.Key @@ -44,3 +45,5 @@ export const layer = Layer.effect( }) }), ) + +export const node = makeLocationNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/tool-output-store.ts b/packages/core/src/tool-output-store.ts index 134ad68470a..e76979e1e5b 100644 --- a/packages/core/src/tool-output-store.ts +++ b/packages/core/src/tool-output-store.ts @@ -5,6 +5,7 @@ import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effe import { Config } from "./config" import { FSUtil } from "./fs-util" import { Global } from "./global" +import { makeGlobalNode, makeLocationNode } from "./effect/node" import { SessionSchema } from "./session/schema" import { Identifier } from "./util/identifier" import type { ToolOutput } from "@opencode-ai/llm" @@ -193,6 +194,8 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.defaultLayer)) +export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node, Config.node] }) + /** Runs retention scanning once globally rather than once per active Location. */ export const cleanupLayer = Layer.effectDiscard( Effect.gen(function* () { @@ -202,3 +205,5 @@ export const cleanupLayer = Layer.effectDiscard( ) export const defaultCleanupLayer = Layer.merge(defaultLayer, cleanupLayer.pipe(Layer.provide(defaultLayer))) + +export const cleanupNode = makeGlobalNode({ name: "tool-output-cleanup", layer: defaultCleanupLayer, deps: [] }) diff --git a/packages/core/src/tool/application-tools.ts b/packages/core/src/tool/application-tools.ts index 5309e4b4ba7..c93dd3ac02b 100644 --- a/packages/core/src/tool/application-tools.ts +++ b/packages/core/src/tool/application-tools.ts @@ -3,6 +3,7 @@ export * as ApplicationTools from "./application-tools" import { Context, Effect, Layer, Scope } from "effect" import { State } from "../state" import { Tool } from "./tool" +import { makeGlobalNode } from "../effect/node" type Data = { readonly entries: Map @@ -52,3 +53,5 @@ export const layer = Layer.effect( }) }), ) + +export const node = makeGlobalNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/tool/builtins.ts b/packages/core/src/tool/builtins.ts index 62926202dc1..7484c40f3f6 100644 --- a/packages/core/src/tool/builtins.ts +++ b/packages/core/src/tool/builtins.ts @@ -1,5 +1,6 @@ export * as BuiltInTools from "./builtins" +import { makeLocationNode } from "../effect/node" import { Layer } from "effect" import { BashTool } from "./bash" import { ApplyPatchTool } from "./apply-patch" @@ -14,6 +15,20 @@ import { TodoWriteTool } from "./todowrite" import { WebFetchTool } from "./webfetch" import { WebSearchTool } from "./websearch" import { WriteTool } from "./write" +import { FSUtil } from "../fs-util" +import { AppProcess } from "../process" +import { Config } from "../config" +import { Location } from "../location" +import { LocationMutation } from "../location-mutation" +import { FileMutation } from "../file-mutation" +import { PermissionV2 } from "../permission" +import { Ripgrep } from "../ripgrep" +import { Image } from "../image" +import { QuestionV2 } from "../question" +import { SkillV2 } from "../skill" +import { SessionTodo } from "../session/todo" +import { ToolRegistry } from "./registry" +import { httpClient } from "../effect/layer-node-platform" /** * Composes only the shipped Location-scoped built-in tool transforms. @@ -42,3 +57,25 @@ export const locationLayer = Layer.mergeAll( WebSearchTool.layer.pipe(Layer.provide(WebSearchTool.defaultConfigLayer)), WriteTool.layer, ) + +export const node = makeLocationNode({ + name: "built-in-tools", + layer: locationLayer, + deps: [ + ToolRegistry.toolsNode, + FSUtil.node, + AppProcess.node, + Config.node, + Location.node, + LocationMutation.node, + FileMutation.node, + PermissionV2.node, + Ripgrep.node, + Image.node, + QuestionV2.node, + SkillV2.node, + SessionTodo.node, + ReadToolFileSystem.node, + httpClient, + ], +}) diff --git a/packages/core/src/tool/read-filesystem.ts b/packages/core/src/tool/read-filesystem.ts index 6f54f796f3d..79d7de3a3d3 100644 --- a/packages/core/src/tool/read-filesystem.ts +++ b/packages/core/src/tool/read-filesystem.ts @@ -5,6 +5,7 @@ import { pathToFileURL } from "url" import { Context, Effect, Layer, Option, Schema } from "effect" import { FileSystem } from "../filesystem" import { FSUtil } from "../fs-util" +import { makeLocationNode } from "../effect/node" import { AbsolutePath, PositiveInt, RelativePath } from "../schema" export const MAX_READ_LINES = 2_000 @@ -361,3 +362,5 @@ export const layer = Layer.effect( }) }), ) + +export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] }) diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index d99cc9014c4..6f423e295e1 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -11,6 +11,7 @@ import { Wildcard } from "../util/wildcard" import { ApplicationTools } from "./application-tools" import { definition, permission, settle, validateName, type AnyTool, type RegistrationError } from "./tool" import { Tools } from "./tools" +import { makeLocationNode } from "../effect/node" export type ExecuteInput = { readonly sessionID: SessionSchema.ID @@ -137,3 +138,15 @@ export const defaultLayer = layer.pipe( Layer.provide(ApplicationTools.layer), Layer.provide(ToolOutputStore.defaultLayer), ) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [ApplicationTools.node, ToolOutputStore.node], +}) + +export const toolsNode = makeLocationNode({ + service: Tools.Service, + layer, + deps: [ApplicationTools.node, ToolOutputStore.node], +}) diff --git a/packages/core/src/util/effect-flock.ts b/packages/core/src/util/effect-flock.ts index 5d255538937..ded170fc6fd 100644 --- a/packages/core/src/util/effect-flock.ts +++ b/packages/core/src/util/effect-flock.ts @@ -6,7 +6,7 @@ import type { FileSystem, Scope } from "effect" import type { PlatformError } from "effect/PlatformError" import { FSUtil } from "../fs-util" import { Global } from "../global" -import { LayerNode } from "../effect/layer-node" +import { makeGlobalNode } from "../effect/node" import { Hash } from "./hash" export namespace EffectFlock { @@ -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({ service: Service, layer: layer, deps: [Global.node, FSUtil.node] }) + export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Global.node, FSUtil.node] }) } diff --git a/packages/core/test/effect/layer-node/layer-node-types.test.ts b/packages/core/test/effect/layer-node/layer-node-types.test.ts new file mode 100644 index 00000000000..a4dce388696 --- /dev/null +++ b/packages/core/test/effect/layer-node/layer-node-types.test.ts @@ -0,0 +1,138 @@ +import { test } from "bun:test" +import { Context, Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node" +import { makeGlobalNode, makeLocationNode } from "@opencode-ai/core/effect/node" + +class A extends Context.Service()("test/LayerNodeA") {} +class B extends Context.Service()("test/LayerNodeB") {} +class C extends Context.Service()("test/LayerNodeC") {} +class LayerError { + readonly _tag = "LayerError" +} +class OtherError { + readonly _tag = "OtherError" +} + +const tags = LayerNode.tags({ app: [] }) +const make = tags.make("app") +const build = (root: LayerNode.Node) => + LayerNodeTree.compile(root) as Layer.Layer +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] }) +const inputA = LayerNode.unbound(A, tags.values.app) +const inputDependent = make({ service: B, layer: bLayer, deps: [inputA] }) + +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 = build(LayerNode.group([c])) +const closedWithError = build(LayerNode.group([dependent])) +const checkClosed: Layer.Layer = closed +const checkError: Layer.Layer = 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) + +class TagA extends Context.Service()("test/TagA") {} +class TagB extends Context.Service()("test/TagB") {} +class TagC extends Context.Service()("test/TagC") {} + +const scopedTags = LayerNode.tags({ request: ["global"], global: [] }) +const request = scopedTags.make("request") +const global = scopedTags.make("global") +const globalA = global({ service: TagA, layer: Layer.succeed(TagA, TagA.of({})), deps: [] }) +const requestA = request({ service: TagA, layer: Layer.succeed(TagA, TagA.of({})), deps: [] }) +const requestB = request({ service: TagB, layer: Layer.succeed(TagB, TagB.of({})), deps: [] }) +const tagBLayer = Layer.effect(TagB, Effect.as(TagA, TagB.of({}))) +const tagCLayer = Layer.effect( + TagC, + Effect.gen(function* () { + yield* TagA + yield* TagB + return TagC.of({}) + }), +) + +request({ service: TagB, layer: tagBLayer, deps: [globalA] }) +request({ service: TagC, layer: tagCLayer, deps: [globalA, requestB] }) +request({ service: TagC, layer: tagCLayer, deps: [LayerNode.group([globalA, requestB])] }) + +// @ts-expect-error Tag configuration can only reference declared tags +LayerNode.tags({ request: ["missing"], global: [] }) + +// @ts-expect-error An unrelated dependency cannot satisfy TagA +request({ service: TagB, layer: tagBLayer, deps: [requestB] }) + +// @ts-expect-error Providing only TagA leaves TagB missing +request({ service: TagC, layer: tagCLayer, deps: [globalA] }) + +// @ts-expect-error Providing only TagB leaves TagA missing +request({ service: TagC, layer: tagCLayer, deps: [requestB] }) + +// @ts-expect-error Duplicate TagA providers still leave TagB missing +request({ service: TagC, layer: tagCLayer, deps: [globalA, requestA] }) + +// @ts-expect-error A group with only TagA still leaves TagB missing +request({ service: TagC, layer: tagCLayer, deps: [LayerNode.group([globalA])] }) + +// @ts-expect-error Global cannot depend on request +global({ service: TagB, layer: tagBLayer, deps: [requestA] }) + +// @ts-expect-error Groups preserve their child tags +global({ service: TagB, layer: tagBLayer, deps: [LayerNode.group([requestA])] }) + +class ScopedA extends Context.Service()("test/ScopedA") {} +class ScopedB extends Context.Service()("test/ScopedB") {} + +const scopedA = Layer.succeed(ScopedA, ScopedA.of({})) +const scopedB = Layer.effect(ScopedB, Effect.as(ScopedA, ScopedB.of({}))) +const globalScopedA = makeGlobalNode({ service: ScopedA, layer: scopedA, deps: [] }) +const locationScopedA = makeLocationNode({ service: ScopedA, layer: scopedA, deps: [] }) + +makeGlobalNode({ service: ScopedB, layer: scopedB, deps: [globalScopedA] }) +makeLocationNode({ service: ScopedB, layer: scopedB, deps: [globalScopedA] }) +makeLocationNode({ service: ScopedB, layer: scopedB, deps: [locationScopedA] }) + +// @ts-expect-error Global nodes cannot depend on location nodes +makeGlobalNode({ service: ScopedB, layer: scopedB, deps: [locationScopedA] }) + +// @ts-expect-error ScopedB requires ScopedA +makeLocationNode({ service: ScopedB, layer: scopedB, deps: [] }) + +test("type exploration compiles", () => {}) diff --git a/packages/core/test/effect/layer-node/layer-node.test.ts b/packages/core/test/effect/layer-node/layer-node.test.ts new file mode 100644 index 00000000000..7c0ffe27c15 --- /dev/null +++ b/packages/core/test/effect/layer-node/layer-node.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, test } from "bun:test" +import { Context, Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node" + +class Value extends Context.Service()("test/LayerNodeValue") {} +class Greeting extends Context.Service()("test/LayerNodeGreeting") {} +class Left extends Context.Service()("test/LayerNodeLeft") {} +class Right extends Context.Service()("test/LayerNodeRight") {} +class Database extends Context.Service()("test/GraphDatabase") {} +class Users extends Context.Service }>()("test/GraphUsers") {} +class App extends Context.Service }>()("test/GraphApp") {} + +const tags = LayerNode.tags({ app: [] }) +const make = tags.make("app") +const build = (root: LayerNode.Node, replacements?: readonly LayerNode.Replacement[]) => + LayerNodeTree.compile( + root, + new Map(replacements?.map((item) => [item.source, item.replacement])), + ) as Layer.Layer +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 untagged 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(LayerNodeTree.compile(LayerNode.group([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(build(LayerNode.group([greeting])))) + expect(await Effect.runPromise(program)).toBe("hello production") + }) + + test("exposes roots but hides transitive dependencies", () => { + const layer = build(LayerNode.group([greeting])) + const check: Layer.Layer = layer + void check + }) + + test("preserves branch-specific implementations across roots", async () => { + const firstValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "first" })), deps: [] }) + const secondValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "second" })), deps: [] }) + 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: [firstValue] }) + const right = make({ service: Right, layer: rightLayer, deps: [secondValue] }) + const layer = build(LayerNode.group([left, right])) + const program = Effect.gen(function* () { + return [(yield* Left).value, (yield* Right).value] + }).pipe(Effect.provide(layer)) + expect(await Effect.runPromise(program)).toEqual(["first", "second"]) + }) + + test("requires unbound nodes to be bound before compilation", async () => { + const unbound = LayerNode.unbound(Value, tags.values.app) + const greeting = make({ service: Greeting, layer: greetingLayer, deps: [unbound] }) + const tree = LayerNode.group([greeting]) + expect(() => LayerNodeTree.compile(tree)).toThrow("Unbound layer node: test/LayerNodeValue") + const bound = LayerNodeTree.bind(tree, unbound, value) + const layer = LayerNodeTree.compile(bound) as Layer.Layer + const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(layer)) + expect(await Effect.runPromise(program)).toBe("hello production") + }) + + test("replaces a layer by identity", async () => { + const replacement = Layer.succeed(Value, Value.of({ value: "simulation" })) + const program = Effect.map(Greeting, (item) => item.value).pipe( + Effect.provide(build(LayerNode.group([greeting]), [LayerNode.replace(valueLayer, replacement)])), + ) + 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 = build(LayerNode.group([left, right]), [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(build(LayerNode.group([greeting]), [LayerNode.replace(other, replacement)])), + ), + ) + expect(acquisitions).toBe(0) + }) + + test("hoists and compiles tagged graphs", async () => { + const tags = LayerNode.tags({ location: ["global"], global: [] }) + const global = tags.make("global") + const location = tags.make("location") + const database = global({ + service: Database, + layer: Layer.succeed(Database, Database.of({ name: "Alice" })), + deps: [], + }) + const users = location({ + service: Users, + layer: Layer.effect( + Users, + Effect.gen(function* () { + const db = yield* Database + return Users.of({ list: Effect.succeed([db.name]) }) + }), + ), + deps: [database], + }) + const app = location({ + service: App, + layer: Layer.effect( + App, + Effect.gen(function* () { + const service = yield* Users + return App.of({ run: service.list }) + }), + ), + deps: [users], + }) + + const result = LayerNodeTree.hoist(LayerNode.group([app]), tags.values.global) + expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({ + kind: "group", + dependencies: [], + }) + expect(result.hoisted.dependencies).toEqual([database]) + + const layer = LayerNodeTree.compile(result.node).pipe( + Layer.provide(LayerNodeTree.compile(result.hoisted)), + ) as unknown as Layer.Layer + const program = Effect.gen(function* () { + return yield* (yield* App).run + }).pipe(Effect.provide(layer)) + + expect(await Effect.runPromise(program)).toEqual(["Alice"]) + }) + + test("rejects conflicting hoisted implementations", () => { + const tags = LayerNode.tags({ location: ["global"], global: [] }) + const global = tags.make("global") + const location = tags.make("location") + const first = global({ + service: Database, + layer: Layer.succeed(Database, Database.of({ name: "first" })), + deps: [], + }) + const second = global({ + service: Database, + layer: Layer.succeed(Database, Database.of({ name: "second" })), + deps: [], + }) + const left = location({ + service: Users, + layer: Layer.effect(Users, Effect.as(Database, Users.of({ list: Effect.succeed([]) }))), + deps: [first], + }) + const right = location({ + service: App, + layer: Layer.effect(App, Effect.as(Database, App.of({ run: Effect.succeed([]) }))), + deps: [second], + }) + + expect(() => LayerNodeTree.hoist(LayerNode.group([left, right]), tags.values.global)).toThrow( + "Tag global has conflicting implementations for test/GraphDatabase", + ) + }) + + test("treats dependency groups as transparent while hoisting", () => { + const tags = LayerNode.tags({ location: ["global"], global: [] }) + const global = tags.make("global") + const location = tags.make("location") + const database = global({ + service: Database, + layer: Layer.succeed(Database, Database.of({ name: "Alice" })), + deps: [], + }) + const users = location({ + service: Users, + layer: Layer.effect(Users, Effect.as(Database, Users.of({ list: Effect.succeed([]) }))), + deps: [LayerNode.group([database])], + }) + const result = LayerNodeTree.hoist(LayerNode.group([users]), tags.values.global) + + expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({ + kind: "group", + dependencies: [], + }) + }) +}) diff --git a/packages/core/test/effect/layer-node/node-build.test.ts b/packages/core/test/effect/layer-node/node-build.test.ts new file mode 100644 index 00000000000..eb09199e5b2 --- /dev/null +++ b/packages/core/test/effect/layer-node/node-build.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from "bun:test" +import { Context, Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Node } from "@opencode-ai/core/effect/node" +import { NodeBuild } from "@opencode-ai/core/effect/node-build" +import { Location } from "@opencode-ai/core/location" +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import { Project } from "@opencode-ai/core/project" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { tmpdir } from "../../fixture/tmpdir" + +class Value extends Context.Service()("test/TagValue") {} +class Result extends Context.Service()("test/TagResult") {} +class Left extends Context.Service()("test/TagLeft") {} +class Right extends Context.Service()("test/TagRight") {} +class Last extends Context.Service()("test/TagLast") {} + +describe("node build", () => { + test("shares top-level project with location services", async () => { + await using tmp = await tmpdir() + let acquisitions = 0 + const projectLayer = Layer.effect( + Project.Service, + Effect.sync(() => { + acquisitions++ + return Project.Service.of({ + directories: () => Effect.succeed([]), + resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }), + commit: () => Effect.void, + }) + }), + ) + const layer = NodeBuild.build(LayerNode.group([Project.node, LocationServiceMap.node]), [ + LayerNode.replace(Project.layer, projectLayer), + ]) + const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }) + const program = Effect.gen(function* () { + yield* Project.Service + const locations = yield* LocationServiceMap.Service + return yield* Location.Service.pipe(Effect.provide(locations.get(ref))) + }).pipe(Effect.provide(layer)) + + expect((await Effect.runPromise(program)).directory).toBe(ref.directory) + expect(acquisitions).toBe(1) + }) + + test("returns a composed application layer", async () => { + const value = Node.makeGlobalNode({ + service: Value, + layer: Layer.succeed(Value, Value.of({ value: "value" })), + deps: [], + }) + const result = Node.makeGlobalNode({ + service: Result, + layer: Layer.effect( + Result, + Effect.gen(function* () { + return Result.of({ value: (yield* Value).value }) + }), + ), + deps: [value], + }) + const serviceLayer = NodeBuild.build(LayerNode.group([result])) + const program = Effect.gen(function* () { + return (yield* Result).value + }).pipe(Effect.provide(serviceLayer)) + + expect(await Effect.runPromise(program)).toBe("value") + }) + + test("rebinds same-tag providers without reacquiring them", async () => { + let firstAcquisitions = 0 + const tags = LayerNode.tags({ global: [] }) + const global = tags.make("global") + const first = global({ + service: Value, + layer: Layer.effect( + Value, + Effect.sync(() => { + firstAcquisitions++ + return Value.of({ value: "first" }) + }), + ), + deps: [], + }) + const second = global({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "second" })), deps: [] }) + const left = global({ + service: Left, + layer: Layer.effect( + Left, + Effect.map(Value, (value) => Left.of({ value: value.value })), + ), + deps: [first], + }) + const right = global({ + service: Right, + layer: Layer.effect( + Right, + Effect.map(Value, (value) => Right.of({ value: value.value })), + ), + deps: [second], + }) + const last = global({ + service: Last, + layer: Layer.effect( + Last, + Effect.map(Value, (value) => Last.of({ value: value.value })), + ), + deps: [first], + }) + const layer = NodeBuild.build(LayerNode.group([left, right, last])) as Layer.Layer + 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) + }) +}) diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 6dba16ece68..b48bcb9ecfe 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -6,7 +6,7 @@ import { Tool } from "@opencode-ai/core/tool/tool" import { define } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" import { ModelV2 } from "@opencode-ai/core/model" @@ -34,7 +34,7 @@ const applicationTools = ApplicationTools.layer const it = testEffect( Layer.merge( Layer.mergeAll(applicationTools, Database.defaultLayer, EventV2.defaultLayer), - LocationServiceMap.layer.pipe( + locationServiceMapLayer.pipe( Layer.provide(applicationTools), Layer.provide( Layer.mergeAll( @@ -60,7 +60,7 @@ describe("LocationServiceMap", () => { Effect.flatMap((dir) => Effect.scoped( Effect.gen(function* () { - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service const directory = AbsolutePath.make(dir.path) const constructed = Location.Ref.make({ directory }) const decoded = Schema.decodeUnknownSync(Location.Ref)({ directory }) @@ -111,7 +111,7 @@ describe("LocationServiceMap", () => { } }).pipe( Effect.scoped, - Effect.provide(LocationServiceMap.get(Location.Ref.make({ directory: AbsolutePath.make(directory) }))), + Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(directory) }))), ) const blockedState = yield* update(blocked.path) @@ -191,7 +191,7 @@ describe("LocationServiceMap", () => { location, }), ), - ).pipe(Effect.provide(LocationServiceMap.get(location)), Effect.flip) + ).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip) expect(failure).toMatchObject({ _tag: "SessionRunnerModel.ModelUnavailableError", @@ -231,7 +231,7 @@ describe("LocationServiceMap", () => { }) }).pipe( Effect.scoped, - Effect.provide(LocationServiceMap.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))), + Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))), ), ), ), diff --git a/packages/core/test/move-session.test.ts b/packages/core/test/move-session.test.ts index 06b40c01547..07f1459335a 100644 --- a/packages/core/test/move-session.test.ts +++ b/packages/core/test/move-session.test.ts @@ -14,7 +14,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionTable } from "@opencode-ai/core/session/sql" @@ -29,7 +29,7 @@ const project = Project.layer.pipe( Layer.provide(ProjectDirectories.defaultLayer), ) const sessions = SessionV2.layer.pipe( - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), Layer.provide(Database.defaultLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(project), diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts index 024b36d439e..dca9f8ead4b 100644 --- a/packages/core/test/permission.test.ts +++ b/packages/core/test/permission.test.ts @@ -11,7 +11,7 @@ import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionStore } from "@opencode-ai/core/session/store" @@ -24,7 +24,7 @@ const current = Layer.succeed( Location.Service.of(location({ directory: AbsolutePath.make("/project") })), ) const sessions = SessionV2.layer.pipe( - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer), Layer.provide(SessionStore.defaultLayer), diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 7826d6bf280..b05745eaa8f 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -13,7 +13,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" @@ -35,7 +35,7 @@ const projects = Layer.succeed( }), ) const sessions = SessionV2.layer.pipe( - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer), Layer.provide(SessionStore.defaultLayer), diff --git a/packages/core/test/session-history.test.ts b/packages/core/test/session-history.test.ts index 8cee6546b0e..9fbfb786d3b 100644 --- a/packages/core/test/session-history.test.ts +++ b/packages/core/test/session-history.test.ts @@ -3,7 +3,7 @@ import { Effect, Layer, Schema } from "effect" import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -23,7 +23,7 @@ const projects = Layer.succeed( }), ) const sessions = SessionV2.layer.pipe( - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer), Layer.provide(SessionStore.defaultLayer), diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 3b568cb9547..da637ae1355 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -10,7 +10,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessage } from "@opencode-ai/core/session/message" import { Prompt } from "@opencode-ai/core/session/prompt" @@ -164,7 +164,7 @@ describe("SessionProjector", () => { }).pipe( Effect.provide( SessionV2.layer.pipe( - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer), Layer.provide(Project.defaultLayer), diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index d60f9f1ccf2..66c8f769144 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -9,7 +9,7 @@ import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionProjector } from "@opencode-ai/core/session/projector" @@ -42,7 +42,7 @@ const execution = Layer.succeed( }), ) const sessions = SessionV2.layer.pipe( - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer), Layer.provide(SessionStore.defaultLayer), diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index da8699fbd2f..a0610dd4652 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -12,7 +12,7 @@ import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Snapshot } from "@opencode-ai/core/snapshot" import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" @@ -103,7 +103,7 @@ const execution = Layer.effect( }), ).pipe(Layer.provide(runner)) const sessions = SessionV2.layer.pipe( - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer), Layer.provide(SessionStore.defaultLayer), diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index c9594e8244f..4779de38dce 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -19,7 +19,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql" import { QuestionV2 } from "@opencode-ai/core/question" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Snapshot } from "@opencode-ai/core/snapshot" import { ContextSnapshotDecodeError } from "@opencode-ai/core/session/error" import { SessionEvent } from "@opencode-ai/core/session/event" @@ -262,7 +262,7 @@ const execution = Layer.effect( }), ).pipe(Layer.provide(runner)) const sessions = SessionV2.layer.pipe( - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer), Layer.provide(SessionStore.defaultLayer), diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index e65c8434939..d6b89eb6b1a 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -27,7 +27,7 @@ import * as OtelTracer from "@effect/opentelemetry/Tracer" import { AbsolutePath, type DeepMutable } from "@opencode-ai/core/schema" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Reference } from "@opencode-ai/core/reference" import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" @@ -93,7 +93,7 @@ export const layer = Layer.effect( const plugin = yield* Plugin.Service const skill = yield* Skill.Service const provider = yield* Provider.Service - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service const state = yield* InstanceState.make( Effect.fn("Agent.state")(function* (ctx) { @@ -444,10 +444,10 @@ export const defaultLayer = layer.pipe( Layer.provide(Auth.defaultLayer), Layer.provide(Config.defaultLayer), Layer.provide(Skill.defaultLayer), - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), ) -const locationServiceMapNode = LayerNode.make({ service: Service, layer: LocationServiceMap.layer, deps: [] }) +const locationServiceMapNode = LayerNode.make({ service: LocationServiceMap.Service, layer: locationServiceMapLayer, deps: [] }) export const node = LayerNode.make({ service: Service, diff --git a/packages/opencode/src/cli/cmd/debug/file.ts b/packages/opencode/src/cli/cmd/debug/file.ts index 0e6ef98c246..02383aeaf96 100644 --- a/packages/opencode/src/cli/cmd/debug/file.ts +++ b/packages/opencode/src/cli/cmd/debug/file.ts @@ -1,7 +1,7 @@ import { EOL } from "os" import { Effect } from "effect" import { FileSystem } from "@opencode-ai/core/filesystem" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { effectCmd } from "../../effect-cmd" @@ -9,8 +9,8 @@ import { cmd } from "../cmd" const filesystem = (effect: Effect.Effect) => effect.pipe( - Effect.provide(LocationServiceMap.get(Location.Ref.make({ directory: AbsolutePath.make(process.cwd()) }))), - Effect.provide(LocationServiceMap.layer), + Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(process.cwd()) }))), + Effect.provide(locationServiceMapLayer), ) const FileSearchCommand = effectCmd({ diff --git a/packages/opencode/src/cli/cmd/debug/v2.ts b/packages/opencode/src/cli/cmd/debug/v2.ts index 87a67b4bc06..c1f82bb79fe 100644 --- a/packages/opencode/src/cli/cmd/debug/v2.ts +++ b/packages/opencode/src/cli/cmd/debug/v2.ts @@ -1,7 +1,7 @@ import { EOL } from "os" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { effectCmd } from "../../effect-cmd" @@ -31,12 +31,12 @@ export const V2Command = effectCmd({ }).pipe( Effect.withSpan("Cli.debug.v2"), Effect.provide( - LocationServiceMap.get( + LocationServiceMap.Service.get( Location.Ref.make({ directory: AbsolutePath.make(process.cwd()), }), ), ), - Effect.provide(LocationServiceMap.layer), + Effect.provide(locationServiceMapLayer), ), }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts index bacc4b0457d..6a826022a0a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -1,6 +1,6 @@ import * as InstanceState from "@/effect/instance-state" import { FileSystem } from "@opencode-ai/core/filesystem" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" @@ -14,7 +14,7 @@ import { InstanceHttpApi } from "../api" export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) => Effect.gen(function* () { const ripgrep = yield* Ripgrep.Service - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service const filesystem = Effect.fnUntraced(function* (effect: Effect.Effect) { return yield* effect.pipe( @@ -136,4 +136,4 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl .handle("content", content) .handle("status", status) }), -).pipe(Layer.provide(LocationServiceMap.layer)) +).pipe(Layer.provide(locationServiceMapLayer)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts index 538920b000e..6446c9d28bc 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -6,7 +6,7 @@ import { Pty } from "@opencode-ai/core/pty" import { PtyProtocol } from "@opencode-ai/core/pty/protocol" import { PtyID } from "@opencode-ai/core/pty/schema" import { PtyTicket } from "@opencode-ai/core/pty/ticket" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { Shell } from "@opencode-ai/core/shell" @@ -43,7 +43,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler const tickets = yield* PtyTicket.Service const cors = yield* CorsConfig const plugin = yield* Plugin.Service - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service const unregister = registerDisposer((directory) => Effect.runPromise(locations.invalidate(Location.Ref.make({ directory: AbsolutePath.make(directory) }))), ) @@ -158,13 +158,13 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler .handle("remove", remove) .handle("connectToken", connectToken) }), -).pipe(Layer.provide(LocationServiceMap.layer)) +).pipe(Layer.provide(locationServiceMapLayer)) export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) => Effect.gen(function* () { const tickets = yield* PtyTicket.Service const cors = yield* CorsConfig - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service const unregister = registerDisposer((directory) => Effect.runPromise(locations.invalidate(Location.Ref.make({ directory: AbsolutePath.make(directory) }))), ) @@ -270,4 +270,4 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne }), ) }), -).pipe(Layer.provide(LocationServiceMap.layer)) +).pipe(Layer.provide(locationServiceMapLayer)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index b74df8deb83..92251f60251 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -52,15 +52,19 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { MoveSession } from "@opencode-ai/core/control-plane/move-session" import { Database } from "@opencode-ai/core/database/database" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node" import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" import { EventV2 } from "@opencode-ai/core/event" import { ModelsDev } from "@opencode-ai/core/models-dev" import { Npm } from "@opencode-ai/core/npm" +import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectCopy } from "@opencode-ai/core/project/copy" import { PtyTicket } from "@opencode-ai/core/pty/ticket" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionV2 } from "@opencode-ai/core/session" +import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local" import { lazy } from "@/util/lazy" import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@opencode-ai/server/cors" import { serveUIEffect } from "@/server/shared/ui" @@ -95,6 +99,10 @@ import { sessionHandlers } from "./handlers/session" import { syncHandlers } from "./handlers/sync" import { tuiHandlers } from "./handlers/tui" import { handlers } from "@opencode-ai/server/handlers" +import { locationServiceMapLayer } from "@opencode-ai/core/location-services" +import { layer as locationLayer } from "@opencode-ai/server/location" +import { sessionLocationLayer } from "@opencode-ai/server/middleware/session-location" +import { PtyEnvironment } from "@opencode-ai/server/pty-environment" import { schemaErrorLayer as v2SchemaErrorLayer } from "@opencode-ai/server/middleware/schema-error" import { workspaceHandlers } from "./handlers/workspace" import { instanceContextLayer } from "./middleware/instance-context" @@ -221,6 +229,7 @@ const app = LayerNode.group([ Discovery.node, Question.node, Permission.node, + PermissionSaved.node, Todo.node, Session.node, SessionProjector.node, @@ -279,9 +288,21 @@ export function createRoutes( MoveSession.defaultLayer, HttpServer.layerServices, ]), - Layer.provide(LayerNode.buildLayer(app)), Layer.provide(Layer.succeed(CorsConfig)(corsOptions)), Layer.provideMerge(Observability.layer), + + Layer.provide(sessionLocationLayer), + Layer.provide(locationLayer), + Layer.provide(PtyEnvironment.layer), + Layer.provide( + SessionV2.defaultLayer.pipe( + Layer.provide(SessionExecutionLocal.defaultLayer), + Layer.provide(locationServiceMapLayer), + ), + ), + Layer.provide(locationServiceMapLayer), + + Layer.provide(LayerNodeTree.compile(app)), ) } diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 798518d0ba5..1bea9f52c3e 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -36,8 +36,6 @@ import type { SystemError } from "bun" import type { Provider } from "@/provider/provider" import { Effect, Schema } from "effect" -export const node = LayerNode.group([Database.node]) - /** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */ interface FetchDecompressionError extends Error { code: "ZlibError" @@ -733,3 +731,4 @@ export function fromError( } export * as MessageV2 from "./message-v2" +export const node = LayerNode.group([Database.node]) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 48c43ca0e8e..f4edf50ed68 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -13,7 +13,7 @@ import { makeRuntime } from "@opencode-ai/core/effect/runtime" import { EventV2Bridge } from "@/event-v2-bridge" import { SessionV2 } from "@opencode-ai/core/session" import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { NotFoundError } from "@/storage/storage" import { eq } from "drizzle-orm" @@ -947,7 +947,7 @@ export const defaultLayer = layer.pipe( Layer.provide( SessionV2.defaultLayer.pipe( Layer.provide(SessionExecutionLocal.defaultLayer), - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), ), ), Layer.provide(RuntimeFlags.defaultLayer), diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index f88795dc77f..29c66bab800 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -18,7 +18,7 @@ import { Permission } from "@/permission" import { Skill } from "@/skill" import { AbsolutePath } from "@opencode-ai/core/schema" import { Location } from "@opencode-ai/core/location" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Reference } from "@opencode-ai/core/reference" import { MCP } from "@/mcp" import { PermissionV1 } from "@opencode-ai/core/v1/permission" @@ -52,7 +52,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const skill = yield* Skill.Service const mcp = yield* MCP.Service - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service return Service.of({ environment: Effect.fn("SystemPrompt.environment")(function* (model: Provider.Model) { @@ -131,10 +131,10 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe( Layer.provide(Skill.defaultLayer), Layer.provide(MCP.defaultLayer), - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), ) -const locationServiceMapNode = LayerNode.make({ service: Service, layer: LocationServiceMap.layer, deps: [] }) +const locationServiceMapNode = LayerNode.make({ service: LocationServiceMap.Service, layer: locationServiceMapLayer, deps: [] }) export const node = LayerNode.make({ service: Service, diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 1df95b5c0f8..b1162bb7e45 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -14,7 +14,7 @@ import { Plugin } from "../../src/plugin" import { Provider } from "../../src/provider/provider" import { Skill } from "../../src/skill" import { Truncate } from "../../src/tool/truncate" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" const agentLayer = (flags: Partial = {}) => Agent.layer.pipe( @@ -23,7 +23,7 @@ const agentLayer = (flags: Partial = {}) => Layer.provide(Auth.defaultLayer), Layer.provide(Config.defaultLayer), Layer.provide(Skill.defaultLayer), - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), Layer.provide(RuntimeFlags.layer(flags)), ) diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index 4c894b0a011..a9a26c72267 100644 --- a/packages/opencode/test/agent/plugin-agent-regression.test.ts +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { FSUtil } from "@opencode-ai/core/fs-util" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Effect, Layer } from "effect" import { FetchHttpClient } from "effect/unstable/http" import path from "path" @@ -44,7 +44,7 @@ const agentLayer = Agent.layer.pipe( Layer.provide(SkillTest.empty), Layer.provide(provider.layer), Layer.provide(pluginLayer), - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), ) diff --git a/packages/opencode/test/effect/layer-node-tiers-types.test.ts b/packages/opencode/test/effect/layer-node-tiers-types.test.ts deleted file mode 100644 index c41b0ccb3c0..00000000000 --- a/packages/opencode/test/effect/layer-node-tiers-types.test.ts +++ /dev/null @@ -1,19 +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()("test/TierA") {} -class B extends Context.Service()("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", () => {}) diff --git a/packages/opencode/test/effect/layer-node-tiers.test.ts b/packages/opencode/test/effect/layer-node-tiers.test.ts deleted file mode 100644 index ec460214a5d..00000000000 --- a/packages/opencode/test/effect/layer-node-tiers.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -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()("test/TierValue") {} -class Result extends Context.Service()("test/TierResult") {} -class Left extends Context.Service()("test/TierLeft") {} -class Right extends Context.Service()("test/TierRight") {} -class Last extends Context.Service()("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) -}) diff --git a/packages/opencode/test/effect/layer-node-types.test.ts b/packages/opencode/test/effect/layer-node-types.test.ts deleted file mode 100644 index 4ff88cf80fc..00000000000 --- a/packages/opencode/test/effect/layer-node-types.test.ts +++ /dev/null @@ -1,66 +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()("test/LayerNodeA") {} -class B extends Context.Service()("test/LayerNodeB") {} -class C extends Context.Service()("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 = closed -const checkError: Layer.Layer = 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", () => {}) diff --git a/packages/opencode/test/effect/layer-node.test.ts b/packages/opencode/test/effect/layer-node.test.ts deleted file mode 100644 index 036d4a501b6..00000000000 --- a/packages/opencode/test/effect/layer-node.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -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()("test/LayerNodeValue") {} -class Greeting extends Context.Service()("test/LayerNodeGreeting") {} -class Left extends Context.Service()("test/LayerNodeLeft") {} -class Right extends Context.Service()("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) - }) -}) diff --git a/packages/opencode/test/effect/scoped-node-types.test.ts b/packages/opencode/test/effect/scoped-node-types.test.ts deleted file mode 100644 index 0521e5c6a45..00000000000 --- a/packages/opencode/test/effect/scoped-node-types.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -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()("test/ScopedA") {} -class B extends Context.Service()("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", () => {}) diff --git a/packages/opencode/test/git/git.test.ts b/packages/opencode/test/git/git.test.ts index c786513c636..ed48fa64054 100644 --- a/packages/opencode/test/git/git.test.ts +++ b/packages/opencode/test/git/git.test.ts @@ -1,4 +1,5 @@ import { $ } from "bun" +import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node" import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" @@ -9,7 +10,7 @@ import { tmpdir } from "../fixture/fixture" import { testEffect } from "../lib/effect" const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt" -const it = testEffect(LayerNode.buildLayer(Git.node)) +const it = testEffect(LayerNodeTree.compile(LayerNode.group([Git.node]))) const scopedTmpdir = (options?: Parameters[0]) => Effect.acquireRelease( diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 92fc6ee622f..641daeead4a 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -21,6 +21,7 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" import { SessionV2 } from "@opencode-ai/core/session" +import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { SessionExecution } from "@opencode-ai/core/session/execution" import type { Provider } from "@/provider/provider" @@ -613,7 +614,9 @@ describe("session.compaction.create", () => { }) const v2 = yield* SessionV2.Service.use((svc) => svc.messages({ sessionID: info.id })).pipe( - Effect.provide(SessionV2.defaultLayer.pipe(Layer.provide(SessionExecution.noopLayer))), + Effect.provide(SessionV2.defaultLayer), + Effect.provide(SessionExecution.noopLayer), + Effect.provide(locationServiceMapLayer), ) expect(v2.at(-1)).toMatchObject({ type: "compaction", diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index 4706235f350..c9e1c3634c2 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node" import { SessionV1 } from "@opencode-ai/core/v1/session" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { SessionProjector } from "@opencode-ai/core/session/projector" @@ -12,7 +13,7 @@ import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -const it = testEffect(LayerNode.buildLayer(LayerNode.group([SessionNs.node, MessageV2.node, SessionProjector.node]))) +const it = testEffect(LayerNodeTree.compile(LayerNode.group([SessionNs.node, MessageV2.node, SessionProjector.node]))) const withSession = ( fn: (input: { session: SessionNs.Interface; sessionID: SessionID }) => Effect.Effect, diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index b653bc10b2e..4263c01bee2 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -1,6 +1,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session" import { Database } from "@opencode-ai/core/database/database" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node" import { EventV2Bridge } from "@/event-v2-bridge" import { expect } from "bun:test" import { tool } from "ai" @@ -179,9 +180,9 @@ const replacements = [ LayerNode.replace(SessionSummary.layer, summary), LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer({ experimentalEventSystem: true })), ] -const env = LayerNode.buildLayer( +const env = LayerNodeTree.compile( LayerNode.group([root, LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] })]), - { replacements }, + new Map(replacements.map((item) => [item.source, item.replacement])), ) const it = testEffect(env) @@ -206,9 +207,12 @@ const providerErrorLLM = Layer.succeed( ), }), ) -const providerErrorEnv = LayerNode.buildLayer(root, { - replacements: [...replacements, LayerNode.replace(LLM.layer, providerErrorLLM)], -}) +const providerErrorEnv = LayerNodeTree.compile( + root, + new Map( + [...replacements, LayerNode.replace(LLM.layer, providerErrorLLM)].map((item) => [item.source, item.replacement]), + ), +) const itProviderError = testEffect(providerErrorEnv) const fragmentFailureLLM = Layer.succeed( @@ -225,9 +229,15 @@ const fragmentFailureLLM = Layer.succeed( ), }), ) -const fragmentFailureEnv = LayerNode.buildLayer(root, { - replacements: [...replacements, LayerNode.replace(LLM.layer, fragmentFailureLLM)], -}) +const fragmentFailureEnv = LayerNodeTree.compile( + root, + new Map( + [...replacements, LayerNode.replace(LLM.layer, fragmentFailureLLM)].map((item) => [ + item.source, + item.replacement, + ]), + ), +) const itFragmentFailure = testEffect(fragmentFailureEnv) const boot = Effect.fn("test.boot")(function* () { diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 460f1acc743..bb98a867fae 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -56,7 +56,7 @@ import { reply, TestLLMServer } from "../lib/llm-server" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" const summary = Layer.succeed( SessionSummary.Service, @@ -230,7 +230,7 @@ function makePrompt(input?: { mcpInstructions?: MCP.ServerInstructions[]; proces Layer.provide( SystemPrompt.layer.pipe( Layer.provide(Skill.defaultLayer), - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), Layer.provide(deps), ), ), @@ -697,7 +697,9 @@ noLLMServer.instance.skip( }) const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe( - Effect.provide(SessionV2.defaultLayer.pipe(Layer.provide(SessionExecution.noopLayer))), + Effect.provide(SessionV2.defaultLayer), + Effect.provide(SessionExecution.noopLayer), + Effect.provide(locationServiceMapLayer), ) const { db } = yield* Database.Service const row = yield* db diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index e53a6c1f18e..acb82a66850 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node" import { SessionV1 } from "@opencode-ai/core/v1/session" import type { NamedError } from "@opencode-ai/core/util/error" import { APICallError } from "ai" @@ -16,7 +17,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" const providerID = ProviderV2.ID.make("test") const retryProvider = "test" -const it = testEffect(LayerNode.buildLayer(LayerNode.group([SessionStatus.node, CrossSpawnSpawner.node]))) +const it = testEffect(LayerNodeTree.compile(LayerNode.group([SessionStatus.node, CrossSpawnSpawner.node]))) function apiError(headers?: Record): SessionV1.APIError { return Schema.decodeUnknownSync(SessionV1.APIError.Schema)( diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 313e5cf9c78..3ffc6a26368 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -14,6 +14,7 @@ import { expect } from "bun:test" import { Effect, Layer } from "effect" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node" import fs from "fs/promises" import path from "path" import { Session } from "@/session/session" @@ -87,13 +88,16 @@ const root = LayerNode.group([ LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] }), ]) const it = testEffect( - LayerNode.buildLayer(root, { - replacements: [ + LayerNodeTree.compile( + root, + new Map( + [ LayerNode.replace(MCP.layer, mcp), LayerNode.replace(LSP.layer, lsp), LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer({ experimentalEventSystem: true })), - ], - }), + ].map((item) => [item.source, item.replacement]), + ), + ), ) const providerCfg = (url: string) => ({ diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index 548f0028c57..c3ff3788d64 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -6,7 +6,7 @@ import { Skill } from "../../src/skill" import { Permission } from "../../src/permission" import { SystemPrompt } from "../../src/session/system" import { MCP } from "../../src/mcp" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { testEffect } from "../lib/effect" const skills: Skill.Info[] = [ @@ -44,7 +44,7 @@ const build: Agent.Info = { const it = testEffect( SystemPrompt.layer.pipe( - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), Layer.provide( Layer.mock(MCP.Service, { instructions: () => diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index c541af52816..bce95e993a6 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect } from "bun:test" import { Effect, Exit, Layer, Option } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodeTree } 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" import { SessionProjector } from "@opencode-ai/core/session/projector" @@ -19,7 +20,7 @@ import { provideTmpdirInstance } from "../fixture/fixture" import { resetDatabase } from "../fixture/db" import { pollWithTimeout, testEffect } from "../lib/effect" -const env = LayerNode.buildLayer(CrossSpawnSpawner.node) +const env = LayerNodeTree.compile(LayerNode.group([CrossSpawnSpawner.node])) const it = testEffect(env) const json = (req: Parameters[0], body: unknown, status = 200) => @@ -34,22 +35,18 @@ const json = (req: Parameters[0], body: unkno 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(FetchHttpClient.layer, Layer.succeed(HttpClient.HttpClient, client))], - }) + const replacement = LayerNode.replace(FetchHttpClient.layer, Layer.succeed(HttpClient.HttpClient, client)) + return LayerNodeTree.compile( + LayerNode.group([ShareNext.node, AccountRepo.node]), + new Map([[replacement.source, replacement.replacement]]), + ) } function integrationLayer(client: HttpClient.HttpClient) { - return LayerNode.buildLayer( - LayerNode.group([ - ShareNext.node, - EventV2Bridge.node, - Session.node, - SessionProjector.node, - AccountRepo.node, - Database.node, - ]), - { replacements: [LayerNode.replace(FetchHttpClient.layer, Layer.succeed(HttpClient.HttpClient, client))] }, + const replacement = LayerNode.replace(FetchHttpClient.layer, Layer.succeed(HttpClient.HttpClient, client)) + return LayerNodeTree.compile( + LayerNode.group([ShareNext.node, EventV2Bridge.node, Session.node, SessionProjector.node, AccountRepo.node, Database.node]), + new Map([[replacement.source, replacement.replacement]]), ) } diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 9c616f1a062..b32a48e9c6b 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -4,6 +4,7 @@ import fs from "fs/promises" import { fileURLToPath, pathToFileURL } from "url" import { Effect, Layer, Result, Schema } from "effect" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node" import { ToolRegistry } from "@/tool/registry" import { Tool } from "@/tool/tool" import { disposeAllInstances, TestInstance } from "../fixture/fixture" @@ -54,11 +55,17 @@ const replacements = [ LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer()), ] -const it = testEffect(LayerNode.buildLayer(root, { replacements })) +const it = testEffect(LayerNodeTree.compile(root, new Map(replacements.map((item) => [item.source, item.replacement])))) const withBrokenPlugin = testEffect( - LayerNode.buildLayer(root, { - replacements: [...replacements, LayerNode.replace(Plugin.layer, brokenPluginLayer)], - }), + LayerNodeTree.compile( + root, + new Map( + [...replacements, LayerNode.replace(Plugin.layer, brokenPluginLayer)].map((item) => [ + item.source, + item.replacement, + ]), + ), + ), ) afterEach(async () => { diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index 33ecd4117d4..3a5e2e777e2 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -1,10 +1,4 @@ -import { SessionV2 } from "@opencode-ai/core/session" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" -import { PermissionSaved } from "@opencode-ai/core/permission/saved" -import { PtyTicket } from "@opencode-ai/core/pty/ticket" import { Layer } from "effect" -import { layer as locationLayer } from "./location" -import { sessionLocationLayer } from "./middleware/session-location" import { MessageHandler } from "./handlers/message" import { ModelHandler } from "./handlers/model" import { ProviderHandler } from "./handlers/provider" @@ -19,18 +13,11 @@ import { HealthHandler } from "./handlers/health" import { PtyHandler } from "./handlers/pty" import { QuestionHandler } from "./handlers/question" import { ReferenceHandler } from "./handlers/reference" -import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local" import { LocationHandler } from "./handlers/location" import { IntegrationHandler } from "./handlers/integration" import { CredentialHandler } from "./handlers/credential" -import { Credential } from "@opencode-ai/core/credential" import { ProjectCopyHandler } from "./handlers/project-copy" -const sessionLayer = SessionV2.defaultLayer.pipe( - Layer.provide(SessionExecutionLocal.defaultLayer), - Layer.provide(LocationServiceMap.layer), -) - export const handlers = Layer.mergeAll( HealthHandler, LocationHandler, @@ -50,12 +37,4 @@ export const handlers = Layer.mergeAll( QuestionHandler, ReferenceHandler, ProjectCopyHandler, -).pipe( - Layer.provide(sessionLocationLayer), - Layer.provide(locationLayer), - Layer.provide(sessionLayer), - Layer.provide(PermissionSaved.defaultLayer), - Layer.provide(PtyTicket.defaultLayer), - Layer.provide(LocationServiceMap.layer), - Layer.provide(Credential.defaultLayer), ) diff --git a/packages/server/src/location.ts b/packages/server/src/location.ts index 483a0733fd4..8ae5aa6e6d0 100644 --- a/packages/server/src/location.ts +++ b/packages/server/src/location.ts @@ -1,12 +1,12 @@ import { Location } from "@opencode-ai/core/location" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap } from "@opencode-ai/core/location-services" import { AbsolutePath } from "@opencode-ai/core/schema" import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { Effect, Layer } from "effect" import { HttpServerRequest } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" -export type LocationServices = Layer.Success> +export type LocationServices = Layer.Success> export class LocationMiddleware extends HttpApiMiddleware.Service()( "@opencode/HttpApiLocation", @@ -49,7 +49,7 @@ function decode(input: string) { export const layer = Layer.effect( LocationMiddleware, Effect.gen(function* () { - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service return LocationMiddleware.of((effect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest diff --git a/packages/server/src/middleware/session-location.ts b/packages/server/src/middleware/session-location.ts index d4e691954b6..86fa80f75c1 100644 --- a/packages/server/src/middleware/session-location.ts +++ b/packages/server/src/middleware/session-location.ts @@ -1,5 +1,5 @@ import { Database } from "@opencode-ai/core/database/database" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" @@ -25,7 +25,7 @@ export const sessionLocationLayer = Layer.effect( SessionLocationMiddleware, Effect.gen(function* () { const { db } = yield* Database.Service - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service return SessionLocationMiddleware.of((effect) => Effect.gen(function* () { diff --git a/packages/server/src/pty-environment.ts b/packages/server/src/pty-environment.ts index fe3a375cdc8..44f5b5d4cf8 100644 --- a/packages/server/src/pty-environment.ts +++ b/packages/server/src/pty-environment.ts @@ -1,6 +1,7 @@ export * as PtyEnvironment from "./pty-environment" import { Context, Effect, Layer } from "effect" +import { makeGlobalNode } from "@opencode-ai/core/effect/node" export interface Interface { readonly get: (input: { directory: string; cwd: string }) => Effect.Effect> @@ -8,9 +9,11 @@ export interface Interface { export class Service extends Context.Service()("@opencode/ServerPtyEnvironment") {} -export const defaultLayer = Layer.succeed( +export const layer = Layer.succeed( Service, Service.of({ get: () => Effect.succeed({}), }), ) + +export const node = makeGlobalNode({ service: Service, layer, deps: [] }) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 7db252a9bfc..2ec8be545a0 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -1,7 +1,17 @@ import { Database } from "@opencode-ai/core/database/database" +import { LayerNode, LayerNodeTree } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" +import { NodeBuild } from "@opencode-ai/core/effect/node-build" import { EventV2 } from "@opencode-ai/core/event" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" -import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http" +import { Credential } from "@opencode-ai/core/credential" +import { PermissionSaved } from "@opencode-ai/core/permission/saved" +import { PtyTicket } from "@opencode-ai/core/pty/ticket" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { HttpRouter, HttpServer } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Layer, Option } from "effect" import { Api } from "./api" @@ -10,6 +20,21 @@ import { handlers } from "./handlers" import { authorizationLayer } from "./middleware/authorization" import { schemaErrorLayer } from "./middleware/schema-error" import { PtyEnvironment } from "./pty-environment" +import { layer as locationLayer } from "./location" +import { sessionLocationLayer } from "./middleware/session-location" + +const applicationServices = LayerNode.group([ + Database.node, + EventV2.node, + httpClient, + ToolOutputStore.cleanupNode, + SessionV2.node, + PermissionSaved.node, + PtyTicket.node, + Credential.node, + PtyEnvironment.node, + LocationServiceMap.node, +]) export function createRoutes(password?: string) { return makeRoutes( @@ -24,16 +49,18 @@ export function createEmbeddedRoutes() { } function makeRoutes(auth: Layer.Layer) { + const serviceLayer = NodeBuild.build( + LayerNodeTree.bind(applicationServices, SessionExecution.node, SessionExecutionLocal.node), + ) + return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( Layer.provide(handlers), - Layer.provide(PtyEnvironment.defaultLayer), + Layer.provide(sessionLocationLayer), + Layer.provide(locationLayer), Layer.provide(authorizationLayer), Layer.provide(schemaErrorLayer), Layer.provide(auth), - Layer.provide(LocationServiceMap.layer), - Layer.provide(Database.defaultLayer), - Layer.provide(EventV2.defaultLayer), - Layer.provide(FetchHttpClient.layer), + Layer.provide(serviceLayer), ) } diff --git a/specs/layer-node-tiers.md b/specs/layer-node-tiers.md deleted file mode 100644 index fc2212ece8c..00000000000 --- a/specs/layer-node-tiers.md +++ /dev/null @@ -1,235 +0,0 @@ -# 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.