From 2f0343ca244b60635ca8c7089446af65ef80ac5e Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 26 Jun 2026 15:41:43 -0400 Subject: [PATCH] wip --- packages/core/src/.#location-layer.ts | 1 + packages/core/src/.#location-mutation.ts | 1 + packages/core/src/agent.ts | 3 + packages/core/src/aisdk.ts | 3 + packages/core/src/background-job.ts | 3 + packages/core/src/catalog.ts | 3 + packages/core/src/command.ts | 3 + packages/core/src/config.ts | 7 + packages/core/src/credential.ts | 3 + packages/core/src/cross-spawn-spawner.ts | 4 +- packages/core/src/database/database.ts | 4 +- .../core/src/effect/layer-node-platform.ts | 12 +- packages/core/src/effect/layer-node-tree.ts | 182 ++++++ packages/core/src/effect/layer-node.ts | 181 ++---- packages/core/src/effect/scoped-node-build.ts | 32 ++ packages/core/src/event.ts | 4 +- packages/core/src/file-mutation.ts | 3 + packages/core/src/filesystem.ts | 7 + packages/core/src/filesystem/search.ts | 9 +- packages/core/src/filesystem/watcher.ts | 7 + packages/core/src/fs-util.ts | 4 +- packages/core/src/git.ts | 4 +- packages/core/src/global.ts | 4 +- packages/core/src/image.ts | 3 + packages/core/src/instruction-context.ts | 7 + packages/core/src/integration.ts | 3 + packages/core/src/location-layer.ts | 254 ++++----- packages/core/src/location-mutation.ts | 7 + packages/core/src/location-service-map.ts | 16 + packages/core/src/location.ts | 11 + packages/core/src/models-dev.ts | 4 +- packages/core/src/npm.ts | 4 +- packages/core/src/permission.ts | 7 + packages/core/src/permission/saved.ts | 3 + packages/core/src/plugin.ts | 16 + packages/core/src/plugin/internal.ts | 31 +- packages/core/src/policy.ts | 3 + packages/core/src/process.ts | 4 +- packages/core/src/project.ts | 4 +- packages/core/src/project/copy.ts | 10 +- packages/core/src/project/directories.ts | 4 +- packages/core/src/pty.ts | 3 + packages/core/src/pty/ticket.ts | 4 +- packages/core/src/question.ts | 3 + packages/core/src/reference.ts | 7 + packages/core/src/reference/guidance.ts | 3 + packages/core/src/repository-cache.ts | 7 + packages/core/src/ripgrep.ts | 4 +- packages/core/src/ripgrep/binary.ts | 4 +- packages/core/src/session.ts | 539 +++++++++--------- packages/core/src/session/execution/local.ts | 11 +- packages/core/src/session/projector.ts | 4 +- packages/core/src/session/runner/llm.ts | 22 + packages/core/src/session/runner/model.ts | 3 + packages/core/src/session/store.ts | 3 + packages/core/src/session/todo.ts | 3 + packages/core/src/skill.ts | 3 + packages/core/src/skill/discovery.ts | 4 + packages/core/src/skill/guidance.ts | 3 + packages/core/src/snapshot.ts | 7 + packages/core/src/system-context/builtins.ts | 9 + packages/core/src/system-context/registry.ts | 3 + packages/core/src/tool-output-store.ts | 5 + packages/core/src/tool/application-tools.ts | 3 + packages/core/src/tool/builtins.ts | 37 ++ packages/core/src/tool/read-filesystem.ts | 3 + packages/core/src/tool/registry.ts | 13 + packages/core/src/util/effect-flock.ts | 4 +- packages/core/test/location-layer.test.ts | 4 +- packages/core/test/move-session.test.ts | 4 +- packages/core/test/permission.test.ts | 4 +- packages/core/test/session-create.test.ts | 4 +- packages/core/test/session-projector.test.ts | 4 +- packages/core/test/session-prompt.test.ts | 4 +- .../core/test/session-runner-recorded.test.ts | 4 +- packages/core/test/session-runner.test.ts | 4 +- packages/opencode/src/agent/agent.ts | 6 +- packages/opencode/src/cli/cmd/debug/file.ts | 4 +- packages/opencode/src/cli/cmd/debug/v2.ts | 4 +- packages/opencode/src/effect/build-node.ts | 13 + .../routes/instance/httpapi/handlers/file.ts | 4 +- .../routes/instance/httpapi/handlers/pty.ts | 6 +- .../server/routes/instance/httpapi/server.ts | 14 +- packages/opencode/src/session/message-v2.ts | 3 +- packages/opencode/src/session/session.ts | 3 +- packages/opencode/src/session/system.ts | 6 +- packages/opencode/test/agent/agent.test.ts | 4 +- .../agent/plugin-agent-regression.test.ts | 4 +- .../effect/layer-node-tiers-types.test.ts | 38 +- .../test/effect/layer-node-tiers.test.ts | 31 +- .../test/effect/layer-node-tree.test.ts | 130 +++++ .../test/effect/layer-node-types.test.ts | 9 +- .../opencode/test/effect/layer-node.test.ts | 57 +- packages/opencode/test/git/git.test.ts | 4 +- .../opencode/test/session/compaction.test.ts | 2 + .../test/session/messages-pagination.test.ts | 4 +- .../test/session/processor-effect.test.ts | 20 +- packages/opencode/test/session/prompt.test.ts | 5 +- packages/opencode/test/session/retry.test.ts | 4 +- .../test/session/snapshot-tool-race.test.ts | 7 +- packages/opencode/test/session/system.test.ts | 4 +- .../opencode/test/share/share-next.test.ts | 23 +- packages/opencode/test/tool/registry.test.ts | 8 +- packages/server/src/handlers.ts | 17 - packages/server/src/routes.ts | 42 +- specs/layer-node-tiers.md | 44 +- 106 files changed, 1385 insertions(+), 756 deletions(-) create mode 120000 packages/core/src/.#location-layer.ts create mode 120000 packages/core/src/.#location-mutation.ts create mode 100644 packages/core/src/effect/layer-node-tree.ts create mode 100644 packages/core/src/effect/scoped-node-build.ts create mode 100644 packages/core/src/location-service-map.ts create mode 100644 packages/opencode/src/effect/build-node.ts create mode 100644 packages/opencode/test/effect/layer-node-tree.test.ts diff --git a/packages/core/src/.#location-layer.ts b/packages/core/src/.#location-layer.ts new file mode 120000 index 0000000000..1047bc7efc --- /dev/null +++ b/packages/core/src/.#location-layer.ts @@ -0,0 +1 @@ +james@james-6.local.2380:1780672839 \ No newline at end of file diff --git a/packages/core/src/.#location-mutation.ts b/packages/core/src/.#location-mutation.ts new file mode 120000 index 0000000000..1047bc7efc --- /dev/null +++ b/packages/core/src/.#location-mutation.ts @@ -0,0 +1 @@ +james@james-6.local.2380:1780672839 \ No newline at end of file diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 91f411684a..d258f6fa21 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/scoped-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 9ea79394f3..c17d102069 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/scoped-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 35724eb8fd..6b930864aa 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/scoped-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 5f90b09f30..df3be4bc32 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/scoped-node" import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect" import { Catalog } from "@opencode-ai/schema/catalog" import { ModelV2 } from "./model" @@ -291,3 +292,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 c65693ebfd..6443924604 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/scoped-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 1f97194ad5..dcd30a3be4 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/scoped-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 caef1db1b8..006cb75385 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/scoped-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 370c07a648..1a42c3f6a7 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/scoped-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 ac64943e46..3d8176b5da 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 "./scoped-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-tree.ts b/packages/core/src/effect/layer-node-tree.ts new file mode 100644 index 0000000000..ebccdbdfa2 --- /dev/null +++ b/packages/core/src/effect/layer-node-tree.ts @@ -0,0 +1,182 @@ +import { Layer } from "effect" +import { LayerNode } from "./layer-node" + +type AnyNode = LayerNode.Node +type RuntimeLayer = Layer.Layer + +type Separated = { + readonly [Name in Names[number]]: N extends LayerNode.Node + ? LayerNode.Node + : never +} + +export function separate( + root: Root, + tiers: LayerNode.Tiers, +): Separated { + const roots = new Map() + const serviceTiers = new Map() + const visited = new Set() + + const validate = (node: AnyNode) => { + if (node.kind === "group") { + node.dependencies.forEach(validate) + return + } + if (visited.has(node)) return + visited.add(node) + const tier = requireTier(node, tiers) + const existing = serviceTiers.get(node.name) + if (existing && existing !== tier) { + throw new Error(`Service ${node.name} belongs to both tier ${existing} and tier ${tier}`) + } + serviceTiers.set(node.name, tier) + node.dependencies.forEach(validate) + } + validate(root) + + for (const node of flatten(root)) { + const tier = requireTier(node, tiers) + const current = roots.get(tier) ?? [] + roots.set(tier, current) + current.push(node) + } + + return Object.fromEntries( + tiers.names.map((name) => [name, LayerNode.group(roots.get(tiers.values[name as Names[number]]) ?? [])]), + ) as Separated +} + +export function hoist( + root: LayerNode.Node, + tier: T, + tiers: LayerNode.Tiers, +): { + readonly node: LayerNode.Node + readonly hoisted: LayerNode.Node +} { + const indexes = new Map(tiers.names.map((name, index) => [tiers.values[name], index])) + const current = indexes.get(tier) + if (current === undefined) throw new Error(`Tier ${tier} is not in the tier configuration`) + const visited = new Map() + const hoisted = new Map() + let hoistedTier: LayerNode.Tier | undefined + 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 + + const dependencyTier = requireTier(node, tiers) + const index = indexes.get(dependencyTier)! + if (index < current) throw new Error(`Tier ${tier} cannot depend on lower tier ${dependencyTier}`) + if (index > current) { + if (hoistedTier && hoistedTier !== dependencyTier) { + throw new Error(`Tree ${tier} hoists dependencies into multiple tiers`) + } + hoistedTier = dependencyTier + const existing = hoisted.get(node.name) + if (existing && existing !== node) { + throw new Error(`Tier ${tier} 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())), + } +} + +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.tier !== replacement.tier) { + throw new Error(`Cannot bind ${source.name} across tiers`) + } + 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] +} + +function requireTier(node: AnyNode, tiers: LayerNode.Tiers): LayerNode.Tier { + const tier = + node.tier ?? (tiers.names.length === 1 && tiers.names[0] === "untiered" ? tiers.values.untiered : undefined) + if (!tier || !tiers.names.some((name) => tiers.values[name] === tier)) { + throw new Error(`Node ${node.name} is not in the tier configuration`) + } + return tier +} + +export * as LayerNodeTree from "./layer-node-tree" diff --git a/packages/core/src/effect/layer-node.ts b/packages/core/src/effect/layer-node.ts index 587274d6c2..2b5f301f85 100644 --- a/packages/core/src/effect/layer-node.ts +++ b/packages/core/src/effect/layer-node.ts @@ -1,6 +1,5 @@ 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 @@ -18,8 +17,8 @@ export type Tier = Name & Brand.Brand<"LayerNode.T const makeTier = Brand.nominal() -export type Node = { - readonly kind: "layer" | "group" +export interface Node { + readonly kind: "layer" | "unbound" | "group" readonly name: string readonly service?: Context.Service.Any readonly implementation?: Layer.Any @@ -34,7 +33,7 @@ type NodeIdentity = | { readonly name: string; readonly service?: never } type DistributiveOmit = A extends unknown ? Omit : never -type NodeInput< +type MakeInput< Implementation extends Layer.Any, Items extends NodeList, T extends Tier | undefined = undefined, @@ -49,8 +48,12 @@ export function make< const Items extends NodeList, const T extends Tier | undefined = undefined, >( - input: NodeInput, -): Node, Layer.Error | Error, T> { + input: MakeInput, +): Node< + Layer.Success, + Layer.Error | Error, + T +> { return { kind: "layer", name: input.service !== undefined ? input.service.key : input.name, @@ -61,7 +64,20 @@ export function make< } } -export function group( +export function unbound( + service: Context.Key, + tier: T, +): Node { + return { + kind: "unbound", + name: service.key, + service, + dependencies: [], + tier, + } +} + +export function group( dependencies: Items, ): Node, Error> { return { kind: "group", name: "group", dependencies } @@ -76,19 +92,26 @@ type AllowedTierNames : never -type NodeInTiers = Node> +type NodeInTiers = Node | undefined> +type CheckTiers = [Exclude>] extends [ + never, +] + ? unknown + : { readonly "Invalid tier dependencies": Exclude> } 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> + ) => ( + input: DistributiveOmit>, "tier"> & + CheckTiers>, + ) => Node< + Layer.Success, + Layer.Error | Error, + Tier + > } export function tiers(names: Names): Tiers { @@ -96,14 +119,11 @@ export function tiers(names: return { names, values, - make: ((name: Names[number]) => (input: DistributiveOmit, "tier">) => + 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 @@ -120,129 +140,4 @@ export function replace( 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/scoped-node-build.ts b/packages/core/src/effect/scoped-node-build.ts new file mode 100644 index 0000000000..3ec8363f96 --- /dev/null +++ b/packages/core/src/effect/scoped-node-build.ts @@ -0,0 +1,32 @@ +import { Layer } from "effect" +import { buildLocationServiceMap, type LocationError, type LocationServices } from "../location-layer" +import { LocationServiceMap, node as locationServiceMapNode } from "../location-service-map" +import { LayerNode } from "./layer-node" +import { LayerNodeTree } from "./layer-node-tree" +import { makeGlobalNode, tiers } from "./scoped-node" + +export function build( + root: LayerNode.Node, + replacements?: readonly LayerNode.Replacement[], +): Layer.Layer { + const separated = LayerNodeTree.separate(root, tiers) + + const location = LayerNodeTree.hoist(separated.location, tiers.values.location, tiers) + + const replacementMap = new Map(replacements?.map((item) => [item.source, item.replacement])) + + const locationMap = buildLocationServiceMap( + location.node as unknown as LayerNode.Node, + replacementMap, + ).pipe(Layer.provide(LayerNodeTree.compile(location.hoisted, replacementMap))) + + const locationMapNode = makeGlobalNode({ service: LocationServiceMap, layer: locationMap, deps: [] }) + const globalNode = LayerNodeTree.bind(separated.global, locationServiceMapNode, locationMapNode) + + return LayerNodeTree.compile(globalNode, replacementMap).pipe(Layer.provideMerge(locationMap)) as Layer.Layer< + LocationServiceMap | A, + LocationError | E + > +} + +export * as ScopedNodeBuild from "./scoped-node-build" diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 5324c319b0..490d671a26 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -7,7 +7,7 @@ import { and, asc, eq, gt } 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/scoped-node" import { isDeepStrictEqual } from "node:util" import { Durable } from "@opencode-ai/schema/durable-event-manifest" @@ -569,6 +569,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 a3c6f519a3..f91aaf521f 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/scoped-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 c02595463f..c6f32a5263 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/scoped-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 db94711480..67aa9fd716 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/scoped-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 5c8930898c..ff00baf300 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/scoped-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 da3d615b59..c8fb3bf44a 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/scoped-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 d6401fcdfe..8e3f549504 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/scoped-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 eed28b94cf..e1c98fda45 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/scoped-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 9c69d6aeb6..210a02e819 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/scoped-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 fdfe59aa0b..88721ffcdf 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/scoped-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 af096df2fd..c70a1da77f 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/scoped-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 index bfbf5c6eed..785a3ba31f 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -1,151 +1,125 @@ 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 { AISDK } from "./aisdk" +import { Catalog } from "./catalog" +import { CommandV2 } from "./command" +import { Config } from "./config" +import { LayerNode } from "./effect/layer-node" +import { LayerNodeTree } from "./effect/layer-node-tree" +import { ScopedNode } from "./effect/scoped-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 { 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 { 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 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), - ) +export { LocationServiceMap, node } from "./location-service-map" - // 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)) +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, +]) - 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, - ], -}) {} +type NodeOutput = N extends LayerNode.Node ? A : never +type NodeError = N extends LayerNode.Node ? E : never +export type LocationServices = NodeOutput +export type LocationError = NodeError +export type LocationTierServices = Exclude + +export function buildLocationServiceMap( + location: LayerNode.Node, + replacements?: ReadonlyMap, +): Layer.Layer { + return Layer.effect( + LocationServiceMap, + LayerMap.make( + (ref: Location.Ref) => { + const layer = LayerNodeTree.compile( + LayerNodeTree.bind(location, Location.node, Location.boundNode(ref)), + replacements, + ) as Layer.Layer + return layer.pipe( + Layer.fresh, + Layer.tap(() => + Effect.logInfo("booting location services", { + directory: ref.directory, + workspaceID: ref.workspaceID, + }), + ), + ) + }, + { idleTimeToLive: "60 minutes" }, + ), + ) +} + +// This is temporary for backwards compatibility +const separatedLocationServices = LayerNodeTree.separate(locationServices, ScopedNode.tiers) +const hoistedLocationServices = LayerNodeTree.hoist( + separatedLocationServices.location, + ScopedNode.tiers.values.location, + ScopedNode.tiers, +) + +export const locationServiceMapLayer = buildLocationServiceMap( + hoistedLocationServices.node as LayerNode.Node, +).pipe(Layer.provide(LayerNodeTree.compile(hoistedLocationServices.hoisted))) as Layer.Layer diff --git a/packages/core/src/location-mutation.ts b/packages/core/src/location-mutation.ts index a620c66e31..73638c431d 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/scoped-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 0000000000..abac2d5571 --- /dev/null +++ b/packages/core/src/location-service-map.ts @@ -0,0 +1,16 @@ +import { Context, Effect, Layer, LayerMap } from "effect" +import { LayerNode } from "./effect/layer-node" +import { ScopedNode } from "./effect/scoped-node" +import { Location } from "./location" +import type { LocationError, LocationServices } from "./location-layer" + +export class LocationServiceMap extends Context.Service< + LocationServiceMap, + LayerMap.LayerMap +>()("@opencode/example/LocationServiceMap") { + static get(ref: Location.Ref) { + return Layer.unwrap(Effect.map(LocationServiceMap, (locations) => locations.get(ref))) + } +} + +export const node = LayerNode.unbound(LocationServiceMap, ScopedNode.tiers.values.global) diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index 21f2150c5c..a60ade1ed9 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, tiers } from "./effect/scoped-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, tiers.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 6d4dc09a79..d344c77410 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/scoped-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 0a13e0f7c3..d5a49dbcd1 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/scoped-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 0ee64a07d7..98a2f7ee36 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/scoped-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 dd9fa5a750..c98170d268 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/scoped-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 95b6357571..4487ab36c7 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/scoped-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 51c6e279b7..e1f4a4217b 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/scoped-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 9b7438f4ff..6185ed7d4b 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/scoped-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 f8d4ef0331..7323b5a971 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/scoped-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 c504c309be..37951640a9 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/scoped-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 d144b15ece..b09d108c77 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/scoped-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 54c31628bf..99c786c1dd 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/scoped-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 9c5d2455a6..3e4da3c682 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/scoped-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 aee1debc78..7d17c4974a 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/scoped-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 2f4942af1b..a1f5f701d5 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/scoped-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 36065ff920..2d531a9c57 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/scoped-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 fa3423c58a..64b3b1b728 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/scoped-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 410b82fea5..5438a72aea 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/scoped-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 c240c6763f..9653682cea 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/scoped-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 18cd6a62c2..fd7894437e 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/scoped-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 59c66b760a..0eedab91fb 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -26,6 +26,9 @@ import { fromRow } from "./session/info" import { SessionRunner } from "./session/runner/index" import { SessionStore } from "./session/store" import { SessionExecution } from "./session/execution" +import { SessionExecutionLocal } from "./session/execution/local" +import { makeGlobalNode } from "./effect/scoped-node" +import { LocationServiceMap, node as locationServiceMapNode } from "./location-service-map" import { MessageDecodeError } from "./session/error" import { SessionEvent } from "./session/event" import { SessionInput } from "./session/input" @@ -170,279 +173,267 @@ 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* () { - 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 decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) - const isDurableSessionEvent = Schema.is(SessionEvent.Durable) - const decode = (row: typeof SessionMessageTable.$inferSelect) => - decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe( - Effect.mapError( - () => - new MessageDecodeError({ - sessionID: SessionSchema.ID.make(row.session_id), - messageID: SessionMessage.ID.make(row.id), - }), +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 decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) + const isDurableSessionEvent = Schema.is(SessionEvent.Durable) + const decode = (row: typeof SessionMessageTable.$inferSelect) => + decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe( + Effect.mapError( + () => + new MessageDecodeError({ + sessionID: SessionSchema.ID.make(row.session_id), + messageID: SessionMessage.ID.make(row.id), + }), + ), + ) + + const result = Service.of({ + create: Effect.fn("V2Session.create")(function* (input) { + const sessionID = input.id ?? SessionSchema.ID.create() + const recorded = yield* store.get(sessionID) + if (recorded) return recorded + const project = yield* projects.resolve(input.location.directory) + yield* db + .insert(ProjectTable) + .values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + const now = Date.now() + const info = SessionV1.SessionInfo.make({ + id: sessionID, + slug: Slug.create(), + version: InstallationVersion, + projectID: project.id, + directory: input.location.directory, + path: path.relative(project.directory, input.location.directory).replaceAll("\\", "/"), + workspaceID: input.location.workspaceID ? WorkspaceV2.ID.make(input.location.workspaceID) : undefined, + title: `New session - ${new Date(now).toISOString()}`, + agent: input.agent, + model: input.model + ? { + id: ModelV2.ID.make(input.model.id), + providerID: input.model.providerID, + variant: input.model.variant, + } + : undefined, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: now, updated: now }, + }) + const projected = yield* events + .publish(SessionV1.Event.Created, { sessionID, info }, { location: input.location }) + .pipe( + Effect.as({ type: "created" } as const), + Effect.catchDefect((defect) => { + if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) { + return Effect.die(defect) + } + // Concurrent creation lost the projection race. The existing Session identity wins. + return store + .get(sessionID) + .pipe( + Effect.flatMap((session) => + session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect), + ), + ) + }), + ) + if (projected.type === "existing") return projected.session + // TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice. + return yield* result.get(sessionID).pipe(Effect.orDie) + }), + get: Effect.fn("V2Session.get")(function* (sessionID) { + const session = yield* store.get(sessionID) + if (!session) return yield* new NotFoundError({ sessionID }) + return session + }), + list: Effect.fn("V2Session.list")(function* (input = {}) { + const direction = input.anchor?.direction ?? "next" + const requestedOrder = input.order ?? "desc" + const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder + const sortColumn = SessionTable.time_created + const conditions: SQL[] = [] + if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory)) + if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) + if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project)) + if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`)) + if (input.anchor) { + conditions.push( + order === "asc" + ? or( + gt(sortColumn, input.anchor.time), + and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)), + )! + : or( + lt(sortColumn, input.anchor.time), + and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)), + )!, + ) + } + const query = db + .select() + .from(SessionTable) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy( + order === "asc" ? asc(sortColumn) : desc(sortColumn), + order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id), + ) + const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe( + Effect.orDie, + ) + return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row)) + }), + messages: Effect.fn("V2Session.messages")(function* (input) { + yield* result.get(input.sessionID) + const direction = input.cursor?.direction ?? "next" + const requestedOrder = input.order ?? "desc" + const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder + const anchor = input.cursor + ? yield* db + .select({ seq: SessionMessageTable.seq }) + .from(SessionMessageTable) + .where( + and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)), + ) + .get() + .pipe(Effect.orDie) + : undefined + if (input.cursor && !anchor) return [] + const boundary = anchor + ? order === "asc" + ? gt(SessionMessageTable.seq, anchor.seq) + : lt(SessionMessageTable.seq, anchor.seq) + : undefined + const where = boundary + ? and(eq(SessionMessageTable.session_id, input.sessionID), boundary) + : eq(SessionMessageTable.session_id, input.sessionID) + const query = db + .select() + .from(SessionMessageTable) + .where(where) + .orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq)) + const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe( + Effect.orDie, + ) + return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, decode) + }), + message: Effect.fn("V2Session.message")(function* (input) { + const stored = yield* store.message(input.messageID) + return stored?.sessionID === input.sessionID ? stored.message : undefined + }), + context: Effect.fn("V2Session.context")(function* (sessionID) { + yield* result.get(sessionID) + return yield* store.context(sessionID) + }), + events: (input) => + Stream.unwrap( + result + .get(input.sessionID) + .pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))), + ).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))), + prompt: Effect.fn("V2Session.prompt")((input) => + Effect.uninterruptible( + Effect.gen(function* () { + yield* result.get(input.sessionID) + const messageID = input.id ?? SessionMessage.ID.create() + const delivery = input.delivery ?? "steer" + const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery } + const admitted = yield* SessionInput.admit(db, events, { + id: messageID, + sessionID: input.sessionID, + prompt: input.prompt, + delivery, + }).pipe( + Effect.catchDefect((defect) => + defect instanceof SessionInput.LifecycleConflict + ? new PromptConflictError({ sessionID: input.sessionID, messageID }) + : Effect.die(defect), ), ) - - const result = Service.of({ - create: Effect.fn("V2Session.create")(function* (input) { - const sessionID = input.id ?? SessionSchema.ID.create() - const recorded = yield* store.get(sessionID) - if (recorded) return recorded - const project = yield* projects.resolve(input.location.directory) - yield* db - .insert(ProjectTable) - .values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] }) - .onConflictDoNothing() - .run() - .pipe(Effect.orDie) - const now = Date.now() - const info = SessionV1.SessionInfo.make({ - id: sessionID, - slug: Slug.create(), - version: InstallationVersion, - projectID: project.id, - directory: input.location.directory, - path: path.relative(project.directory, input.location.directory).replaceAll("\\", "/"), - workspaceID: input.location.workspaceID ? WorkspaceV2.ID.make(input.location.workspaceID) : undefined, - title: `New session - ${new Date(now).toISOString()}`, - agent: input.agent, - model: input.model - ? { - id: ModelV2.ID.make(input.model.id), - providerID: input.model.providerID, - variant: input.model.variant, - } - : undefined, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: now, updated: now }, - }) - const projected = yield* events - .publish(SessionV1.Event.Created, { sessionID, info }, { location: input.location }) - .pipe( - Effect.as({ type: "created" } as const), - Effect.catchDefect((defect) => { - if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) { - return Effect.die(defect) - } - // Concurrent creation lost the projection race. The existing Session identity wins. - return store - .get(sessionID) - .pipe( - Effect.flatMap((session) => - session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect), - ), - ) - }), - ) - if (projected.type === "existing") return projected.session - // TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice. - return yield* result.get(sessionID).pipe(Effect.orDie) - }), - get: Effect.fn("V2Session.get")(function* (sessionID) { - const session = yield* store.get(sessionID) - if (!session) return yield* new NotFoundError({ sessionID }) - return session - }), - list: Effect.fn("V2Session.list")(function* (input = {}) { - const direction = input.anchor?.direction ?? "next" - const requestedOrder = input.order ?? "desc" - const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder - const sortColumn = SessionTable.time_created - const conditions: SQL[] = [] - if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory)) - if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) - if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project)) - if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`)) - if (input.anchor) { - conditions.push( - order === "asc" - ? or( - gt(sortColumn, input.anchor.time), - and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)), - )! - : or( - lt(sortColumn, input.anchor.time), - and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)), - )!, - ) - } - const query = db - .select() - .from(SessionTable) - .where(conditions.length > 0 ? and(...conditions) : undefined) - .orderBy( - order === "asc" ? asc(sortColumn) : desc(sortColumn), - order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id), - ) - const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe( - Effect.orDie, - ) - return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row)) - }), - messages: Effect.fn("V2Session.messages")(function* (input) { - yield* result.get(input.sessionID) - const direction = input.cursor?.direction ?? "next" - const requestedOrder = input.order ?? "desc" - const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder - const anchor = input.cursor - ? yield* db - .select({ seq: SessionMessageTable.seq }) - .from(SessionMessageTable) - .where( - and( - eq(SessionMessageTable.session_id, input.sessionID), - eq(SessionMessageTable.id, input.cursor.id), - ), - ) - .get() - .pipe(Effect.orDie) - : undefined - if (input.cursor && !anchor) return [] - const boundary = anchor - ? order === "asc" - ? gt(SessionMessageTable.seq, anchor.seq) - : lt(SessionMessageTable.seq, anchor.seq) - : undefined - const where = boundary - ? and(eq(SessionMessageTable.session_id, input.sessionID), boundary) - : eq(SessionMessageTable.session_id, input.sessionID) - const query = db - .select() - .from(SessionMessageTable) - .where(where) - .orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq)) - const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe( - Effect.orDie, - ) - return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, decode) - }), - message: Effect.fn("V2Session.message")(function* (input) { - const stored = yield* store.message(input.messageID) - return stored?.sessionID === input.sessionID ? stored.message : undefined - }), - context: Effect.fn("V2Session.context")(function* (sessionID) { - yield* result.get(sessionID) - return yield* store.context(sessionID) - }), - events: (input) => - Stream.unwrap( - result - .get(input.sessionID) - .pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))), - ).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))), - prompt: Effect.fn("V2Session.prompt")((input) => - Effect.uninterruptible( - Effect.gen(function* () { - yield* result.get(input.sessionID) - const messageID = input.id ?? SessionMessage.ID.create() - const delivery = input.delivery ?? "steer" - const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery } - const admitted = yield* SessionInput.admit(db, events, { - id: messageID, - sessionID: input.sessionID, - prompt: input.prompt, - delivery, - }).pipe( - Effect.catchDefect((defect) => - defect instanceof SessionInput.LifecycleConflict - ? new PromptConflictError({ sessionID: input.sessionID, messageID }) - : Effect.die(defect), - ), - ) - if (!SessionInput.equivalent(admitted, expected)) - return yield* new PromptConflictError({ sessionID: input.sessionID, messageID }) - if (input.resume !== false) yield* execution.wake(admitted.sessionID) - return admitted - }), - ), - ), - shell: Effect.fn("V2Session.shell")(function* () { - return yield* new OperationUnavailableError({ operation: "shell" }) - }), - skill: Effect.fn("V2Session.skill")(function* () { - return yield* new OperationUnavailableError({ operation: "skill" }) - }), - switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { - yield* result.get(input.sessionID) - yield* events.publish(SessionEvent.AgentSwitched, { - sessionID: input.sessionID, - messageID: SessionMessage.ID.create(), - timestamp: yield* DateTime.now, - agent: input.agent, - }) - }), - switchModel: Effect.fn("V2Session.switchModel")(function* (input) { - yield* result.get(input.sessionID) - yield* events.publish(SessionEvent.ModelSwitched, { - sessionID: input.sessionID, - messageID: SessionMessage.ID.create(), - timestamp: yield* DateTime.now, - model: input.model, - }) - }), - compact: Effect.fn("V2Session.compact")(function* (input) { - yield* result.get(input.sessionID) - return yield* new OperationUnavailableError({ operation: "compact" }) - }), - wait: Effect.fn("V2Session.wait")(function* (sessionID) { - yield* result.get(sessionID) - return yield* new OperationUnavailableError({ operation: "wait" }) - }), - resume: Effect.fn("V2Session.resume")(function* (sessionID) { - yield* result.get(sessionID) - yield* execution.resume(sessionID) - }), - interrupt: Effect.fn("V2Session.interrupt")((sessionID) => - Effect.uninterruptible(execution.interrupt(sessionID)), - ), - revert: { - stage: Effect.fn("V2Session.revert.stage")(function* (input) { - const session = yield* result.get(input.sessionID) - return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe( - Effect.provideService(Database.Service, database), - Effect.provideService(EventV2.Service, events), - Effect.provide(locations.get(session.location)), - ) - }), - clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) { - const session = yield* result.get(sessionID) - yield* SessionRevert.clear(session).pipe( - Effect.provideService(EventV2.Service, events), - Effect.provide(locations.get(session.location)), - ) - }), - commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) { - const session = yield* result.get(sessionID) - yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events)) - }), - }, - }) - - return result - }), + if (!SessionInput.equivalent(admitted, expected)) + return yield* new PromptConflictError({ sessionID: input.sessionID, messageID }) + if (input.resume !== false) yield* execution.wake(admitted.sessionID) + return admitted + }), + ), ), - ), - ), + shell: Effect.fn("V2Session.shell")(function* () { + return yield* new OperationUnavailableError({ operation: "shell" }) + }), + skill: Effect.fn("V2Session.skill")(function* () { + return yield* new OperationUnavailableError({ operation: "skill" }) + }), + switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { + yield* result.get(input.sessionID) + yield* events.publish(SessionEvent.AgentSwitched, { + sessionID: input.sessionID, + messageID: SessionMessage.ID.create(), + timestamp: yield* DateTime.now, + agent: input.agent, + }) + }), + switchModel: Effect.fn("V2Session.switchModel")(function* (input) { + yield* result.get(input.sessionID) + yield* events.publish(SessionEvent.ModelSwitched, { + sessionID: input.sessionID, + messageID: SessionMessage.ID.create(), + timestamp: yield* DateTime.now, + model: input.model, + }) + }), + compact: Effect.fn("V2Session.compact")(function* (input) { + yield* result.get(input.sessionID) + return yield* new OperationUnavailableError({ operation: "compact" }) + }), + wait: Effect.fn("V2Session.wait")(function* (sessionID) { + yield* result.get(sessionID) + return yield* new OperationUnavailableError({ operation: "wait" }) + }), + resume: Effect.fn("V2Session.resume")(function* (sessionID) { + yield* result.get(sessionID) + yield* execution.resume(sessionID) + }), + interrupt: Effect.fn("V2Session.interrupt")((sessionID) => + Effect.uninterruptible(execution.interrupt(sessionID)), + ), + revert: { + stage: Effect.fn("V2Session.revert.stage")(function* (input) { + const session = yield* result.get(input.sessionID) + return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe( + Effect.provideService(Database.Service, database), + Effect.provideService(EventV2.Service, events), + Effect.provide(locations.get(session.location)), + ) + }), + clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) { + const session = yield* result.get(sessionID) + yield* SessionRevert.clear(session).pipe( + Effect.provideService(EventV2.Service, events), + Effect.provide(locations.get(session.location)), + ) + }), + commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) { + const session = yield* result.get(sessionID) + yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events)) + }), + }, + }) + + 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(SessionExecution.noopLayer), Layer.provide(SessionStore.defaultLayer), Layer.provide(SessionProjector.defaultLayer), @@ -451,3 +442,17 @@ export const defaultLayer = layer.pipe( Layer.provide(ProjectV2.defaultLayer), Layer.orDie, ) + +export const node = makeGlobalNode({ + service: Service, + layer: layer.pipe(Layer.orDie), + deps: [ + Database.node, + EventV2.node, + ProjectV2.node, + SessionExecutionLocal.node, + SessionStore.node, + locationServiceMapNode, + SessionProjector.node, + ], +}) diff --git a/packages/core/src/session/execution/local.ts b/packages/core/src/session/execution/local.ts index 7e0e3ca700..859601dbdf 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, node as locationServiceMapNode } from "../../location-service-map" +import { makeGlobalNode } from "../../effect/scoped-node" import { SessionRunCoordinator } from "../run-coordinator" import { SessionRunner } from "../runner" import { SessionSchema } from "../schema" @@ -36,3 +37,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, locationServiceMapNode], +}) + +export * as SessionExecutionLocal from "./local" diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 4d8b9a05b4..ab9a1e55ea 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/scoped-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 9e29ae71ee..b617fc2db5 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/scoped-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 b864df8ba3..f2f6f2a09d 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/scoped-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 1cff231306..9a0f2d5e80 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/scoped-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 c5ad70afad..c59ba3cefc 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/scoped-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 6c1d37f305..ad16d392a6 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/scoped-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 6402dd3b71..0735413af5 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/scoped-node" +import { httpClient } from "../effect/layer-node-platform" import { AbsolutePath } from "../schema" const skillConcurrency = 4 @@ -165,3 +167,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 81a33f0164..e54441ab60 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/scoped-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 631bca2a23..88959da372 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/scoped-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 42cba27b92..dff1bd269d 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/scoped-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 a2a7ca7e62..36086f44d1 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/scoped-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 134ad68470..5e43b9078c 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/scoped-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 5309e4b4ba..b7ebd47698 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/scoped-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 62926202dc..70ce8e5336 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/scoped-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 6f54f796f3..b1d18fd677 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/scoped-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 d99cc9014c..004ad38333 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/scoped-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 5d25553893..882531113e 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/scoped-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/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 6dba16ece6..a288449acb 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-layer" 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( diff --git a/packages/core/test/move-session.test.ts b/packages/core/test/move-session.test.ts index 0a7db8826f..3ae71dce39 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-layer" 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 024b36d439..2cfbd43a3a 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-layer" 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 7826d6bf28..76d80ee75a 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-layer" 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-projector.test.ts b/packages/core/test/session-projector.test.ts index 3b568cb954..eba6feebef 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-layer" 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 af30c4d877..f3de9bf03d 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-layer" import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionProjector } from "@opencode-ai/core/session/projector" @@ -40,7 +40,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 077ee48823..aeb32943ca 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-layer" import { Snapshot } from "@opencode-ai/core/snapshot" import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" @@ -102,7 +102,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 6df50ac575..7d8a816568 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-layer" import { Snapshot } from "@opencode-ai/core/snapshot" import { ContextSnapshotDecodeError } from "@opencode-ai/core/session/error" import { SessionEvent } from "@opencode-ai/core/session/event" @@ -261,7 +261,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 e65c843493..a9aa956219 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-layer" import { Reference } from "@opencode-ai/core/reference" import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" @@ -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, 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 0e6ef98c24..c3ecd07df1 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-layer" import { Location } from "@opencode-ai/core/location" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { effectCmd } from "../../effect-cmd" @@ -10,7 +10,7 @@ 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(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 87a67b4bc0..5e65768ac5 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-layer" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { effectCmd } from "../../effect-cmd" @@ -37,6 +37,6 @@ export const V2Command = effectCmd({ }), ), ), - Effect.provide(LocationServiceMap.layer), + Effect.provide(locationServiceMapLayer), ), }) diff --git a/packages/opencode/src/effect/build-node.ts b/packages/opencode/src/effect/build-node.ts new file mode 100644 index 0000000000..cbc3d7ca2f --- /dev/null +++ b/packages/opencode/src/effect/build-node.ts @@ -0,0 +1,13 @@ +import { Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node-tree" + +export function buildNode( + root: LayerNode.Node, + replacements?: readonly LayerNode.Replacement[], +): Layer.Layer { + return LayerNodeTree.compile( + root, + new Map(replacements?.map((item) => [item.source, item.replacement])), + ) as Layer.Layer +} 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 bacc4b0457..a3f980bf63 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-layer" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" @@ -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 538920b000..d0e876b4f3 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-layer" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { Shell } from "@opencode-ai/core/shell" @@ -158,7 +158,7 @@ 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* () { @@ -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 b74df8deb8..cdb69e8130 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -1,4 +1,5 @@ import { Config as EffectConfig, Context, Effect, Layer } from "effect" +import { buildNode } from "@/effect/build-node" import { HttpApiBuilder, OpenApi } from "effect/unstable/httpapi" import { HttpClient, HttpMiddleware, HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" @@ -52,6 +53,7 @@ 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-tree" 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" @@ -61,6 +63,7 @@ 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 { lazy } from "@/util/lazy" import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@opencode-ai/server/cors" import { serveUIEffect } from "@/server/shared/ui" @@ -95,6 +98,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-layer" +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" @@ -279,7 +286,12 @@ export function createRoutes( MoveSession.defaultLayer, HttpServer.layerServices, ]), - Layer.provide(LayerNode.buildLayer(app)), + Layer.provide(sessionLocationLayer), + Layer.provide(locationLayer), + Layer.provide(PtyEnvironment.defaultLayer), + Layer.provide(SessionV2.defaultLayer.pipe(Layer.provide(locationServiceMapLayer))), + Layer.provide(buildNode(app)), + Layer.provide(locationServiceMapLayer), Layer.provide(Layer.succeed(CorsConfig)(corsOptions)), Layer.provideMerge(Observability.layer), ) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 798518d0ba..1bea9f52c3 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 13527164be..c954ab7bdd 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -12,6 +12,7 @@ import { Database } from "@opencode-ai/core/database/database" import { makeRuntime } from "@opencode-ai/core/effect/runtime" import { EventV2Bridge } from "@/event-v2-bridge" import { SessionV2 } from "@opencode-ai/core/session" +import { locationServiceMapLayer } from "@opencode-ai/core/location-layer" import { SessionExecution } from "@opencode-ai/core/session/execution" import { NotFoundError } from "@/storage/storage" @@ -924,7 +925,7 @@ export const defaultLayer = layer.pipe( Layer.provide(Database.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(SessionExecution.noopLayer), - Layer.provide(SessionV2.defaultLayer), + Layer.provide(SessionV2.defaultLayer.pipe(Layer.provide(locationServiceMapLayer))), Layer.provide(RuntimeFlags.defaultLayer), ) diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index f88795dc77..ed1a4d57dd 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-layer" import { Reference } from "@opencode-ai/core/reference" import { MCP } from "@/mcp" import { PermissionV1 } from "@opencode-ai/core/v1/permission" @@ -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, 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 1df95b5c0f..7e15c95546 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-layer" 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 4c894b0a01..1cd1a1736c 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-layer" 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 index c41b0ccb3c..07a7cd7075 100644 --- a/packages/opencode/test/effect/layer-node-tiers-types.test.ts +++ b/packages/opencode/test/effect/layer-node-tiers-types.test.ts @@ -1,19 +1,55 @@ 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-tree" class A extends Context.Service()("test/TierA") {} class B extends Context.Service()("test/TierB") {} +class C extends Context.Service()("test/TierC") {} 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 requestA = request({ service: A, layer: Layer.succeed(A, A.of({})), deps: [] }) +const requestB = request({ service: B, layer: Layer.succeed(B, B.of({})), deps: [] }) 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 requestInput = LayerNode.unbound(A, tiers.values.request) +const globalInput = LayerNode.unbound(B, tiers.values.global) +const inputs = LayerNode.group([requestInput, globalInput]) +const separatedInputs = LayerNodeTree.separate(inputs, tiers) +void separatedInputs request({ service: B, layer: bLayer, deps: [globalA] }) +request({ service: C, layer: cLayer, deps: [globalA, requestB] }) +request({ service: C, layer: cLayer, deps: [LayerNode.group([globalA, requestB])] }) +request({ service: B, layer: bLayer, deps: [LayerNode.unbound(A, tiers.values.request)] }) + +// @ts-expect-error An unrelated dependency cannot satisfy A +request({ service: B, layer: bLayer, deps: [requestB] }) + +// @ts-expect-error Providing only A leaves B missing +request({ service: C, layer: cLayer, deps: [globalA] }) + +// @ts-expect-error Providing only B leaves A missing +request({ service: C, layer: cLayer, deps: [requestB] }) + +// @ts-expect-error Duplicate A providers still leave B missing +request({ service: C, layer: cLayer, deps: [globalA, requestA] }) + +// @ts-expect-error A group with only A still leaves B missing +request({ service: C, layer: cLayer, deps: [LayerNode.group([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: [] })] }) +global({ service: B, layer: bLayer, deps: [requestA] }) 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 index ec460214a5..c7cd11285d 100644 --- a/packages/opencode/test/effect/layer-node-tiers.test.ts +++ b/packages/opencode/test/effect/layer-node-tiers.test.ts @@ -1,6 +1,11 @@ import { 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-tree" +import { ScopedNode } from "@opencode-ai/core/effect/scoped-node" +import { ScopedNodeBuild } from "@opencode-ai/core/effect/scoped-node-build" +import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { Location } from "@opencode-ai/core/location" class Value extends Context.Service()("test/TierValue") {} class Result extends Context.Service()("test/TierResult") {} @@ -8,8 +13,7 @@ class Left extends Context.Service()("test/Tie 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 +test("returns independently composable tier layers", async () => { const tiers = LayerNode.tiers(["location", "global"]) const global = tiers.make("global") const location = tiers.make("location") @@ -24,20 +28,15 @@ test("builds tiers with a custom builder", async () => { ), 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 serviceLayer = ScopedNodeBuild.build(LayerNode.group([result])) + const layer = LocationServiceMap.get({ directory: "/tmp" } as Location.Ref).pipe( + Layer.provide(serviceLayer), + ) as unknown as Layer.Layer 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", () => { @@ -57,7 +56,7 @@ test("rejects conflicting higher-tier service implementations", () => { deps: [second], }) - expect(() => LayerNode.buildLayer(LayerNode.group([left, right]), { tiers })).toThrow( + expect(() => ScopedNodeBuild.build(LayerNode.group([left, right]))).toThrow( "conflicting implementations for test/TierValue", ) }) @@ -76,7 +75,7 @@ test("validates tier dependencies through groups", () => { deps: [LayerNode.group([local])], }) - expect(() => LayerNode.buildLayer(invalid, { tiers })).toThrow("Tier global cannot depend on lower tier location") + expect(() => ScopedNodeBuild.build(LayerNode.group([invalid]))).toThrow("Tier global cannot depend on lower tier location") }) test("validates shared groups in each consumer tier", () => { @@ -102,7 +101,7 @@ test("validates shared groups in each consumer tier", () => { deps: [shared], }) - expect(() => LayerNode.buildLayer(LayerNode.group([valid, invalid]), { tiers })).toThrow( + expect(() => ScopedNodeBuild.build(LayerNode.group([valid, invalid]))).toThrow( "Tier global cannot depend on lower tier location", ) }) @@ -114,7 +113,7 @@ test("rejects a service assigned to multiple tiers", () => { 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( + expect(() => ScopedNodeBuild.build(LayerNode.group([local, shared]))).toThrow( "Service test/TierValue belongs to both tier location and tier global", ) }) @@ -159,7 +158,7 @@ test("rebinds same-tier providers without reacquiring them", async () => { ), deps: [first], }) - const layer = LayerNode.buildLayer(LayerNode.group([left, right, last]), { tiers }) + const layer = ScopedNodeBuild.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)) diff --git a/packages/opencode/test/effect/layer-node-tree.test.ts b/packages/opencode/test/effect/layer-node-tree.test.ts new file mode 100644 index 0000000000..1f904bbefc --- /dev/null +++ b/packages/opencode/test/effect/layer-node-tree.test.ts @@ -0,0 +1,130 @@ +import { 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-tree" +import { ScopedNode } from "@opencode-ai/core/effect/scoped-node" +import { ScopedNodeBuild } from "@opencode-ai/core/effect/scoped-node-build" +import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { Location } from "@opencode-ai/core/location" + +class Database extends Context.Service()("test/GraphDatabase") {} +class Users extends Context.Service }>()("test/GraphUsers") {} +class App extends Context.Service }>()("test/GraphApp") {} + +test("separates, hoists, and compiles tier graphs", async () => { + const tiers = LayerNode.tiers(["location", "global"]) + const global = tiers.make("global") + const location = tiers.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 separated = LayerNodeTree.separate(LayerNode.group([app]), tiers) + expect(separated.location.dependencies).toEqual([app]) + expect(separated.global.dependencies).toEqual([]) + + const locationResult = LayerNodeTree.hoist(separated.location, tiers.values.location, tiers) + const globalResult = LayerNodeTree.hoist( + { ...separated.global, dependencies: [...separated.global.dependencies, ...locationResult.hoisted.dependencies] }, + tiers.values.global, + tiers, + ) + expect(locationResult.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({ + kind: "group", + dependencies: [], + }) + expect(locationResult.hoisted.dependencies).toEqual([database]) + + const layer = LayerNodeTree.compile(locationResult.node).pipe( + Layer.provide(LayerNodeTree.compile(globalResult.node)), + ) 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 tiers = LayerNode.tiers(["location", "global"]) + const global = tiers.make("global") + const location = tiers.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] }) + + const separated = LayerNodeTree.separate(LayerNode.group([left, right]), tiers) + expect(() => LayerNodeTree.hoist(separated.location, tiers.values.location, tiers)).toThrow( + "Tier location has conflicting implementations for test/GraphDatabase", + ) +}) + +test("treats dependency groups as transparent while hoisting", () => { + const tiers = LayerNode.tiers(["location", "global"]) + const global = tiers.make("global") + const location = tiers.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 separated = LayerNodeTree.separate(LayerNode.group([users]), tiers) + const result = LayerNodeTree.hoist(separated.location, tiers.values.location, tiers) + + expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({ + kind: "group", + dependencies: [], + }) +}) + +test("builds the scoped location and global trees", async () => { + const database = ScopedNode.makeGlobalNode({ + service: Database, + layer: Layer.succeed(Database, Database.of({ name: "Alice" })), + deps: [], + }) + const app = ScopedNode.makeLocationNode({ + service: App, + layer: Layer.effect(App, Effect.map(Database, (db) => App.of({ run: Effect.succeed([db.name]) }))), + deps: [database], + }) + const serviceLayer = ScopedNodeBuild.build(LayerNode.group([app])) + const layer = LocationServiceMap.get({ directory: "/tmp" } as Location.Ref).pipe( + Layer.provide(serviceLayer), + ) 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"]) +}) diff --git a/packages/opencode/test/effect/layer-node-types.test.ts b/packages/opencode/test/effect/layer-node-types.test.ts index 4ff88cf80f..8b55cd2bbe 100644 --- a/packages/opencode/test/effect/layer-node-types.test.ts +++ b/packages/opencode/test/effect/layer-node-types.test.ts @@ -1,6 +1,7 @@ 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-tree" class A extends Context.Service()("test/LayerNodeA") {} class B extends Context.Service()("test/LayerNodeB") {} @@ -14,6 +15,8 @@ class OtherError { const tiers = LayerNode.tiers(["app"]) const make = tiers.make("app") +const build = (root: LayerNode.Node) => + LayerNodeTree.compile(LayerNodeTree.separate(root, tiers).app) as Layer.Layer const aLayer = Layer.succeed(A, A.of({})) const bLayer = Layer.effect(B, Effect.as(A, B.of({}))) const cLayer = Layer.effect( @@ -30,6 +33,8 @@ 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, tiers.values.app) +const inputDependent = make({ service: B, layer: bLayer, deps: [inputA] }) make({ name: "manual-a", layer: aLayer, deps: [] }) @@ -45,8 +50,8 @@ 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 closed = build(LayerNode.group([c])) +const closedWithError = build(LayerNode.group([dependent])) const checkClosed: Layer.Layer = closed const checkError: Layer.Layer = closedWithError void checkClosed diff --git a/packages/opencode/test/effect/layer-node.test.ts b/packages/opencode/test/effect/layer-node.test.ts index 353cb7d230..bcfaecb484 100644 --- a/packages/opencode/test/effect/layer-node.test.ts +++ b/packages/opencode/test/effect/layer-node.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test" +import { buildNode } from "@/effect/build-node" import { Context, Effect, Layer } from "effect" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node-tree" class Value extends Context.Service()("test/LayerNodeValue") {} class Greeting extends Context.Service()("test/LayerNodeGreeting") {} @@ -9,6 +11,11 @@ class Right extends Context.Service()("test/L const tiers = LayerNode.tiers(["app"]) const make = tiers.make("app") +const build = (root: LayerNode.Node, replacements?: readonly LayerNode.Replacement[]) => + LayerNodeTree.compile( + LayerNodeTree.separate(root, tiers).app, + 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, @@ -21,21 +28,54 @@ 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))) + const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(buildNode(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(LayerNode.buildLayer(greeting, { tiers })), + 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, tiers.values.app) + const greeting = make({ service: Greeting, layer: greetingLayer, deps: [unbound] }) + const tree = LayerNodeTree.separate(LayerNode.group([greeting]), tiers).app + 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(LayerNode.buildLayer(greeting, { tiers, replacements: [LayerNode.replace(valueLayer, replacement)] })), + Effect.provide( + build(LayerNode.group([greeting]), [LayerNode.replace(valueLayer, replacement)]), + ), ) expect(await Effect.runPromise(program)).toBe("hello simulation") }) @@ -52,10 +92,9 @@ describe("layer node", () => { 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 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)) @@ -74,7 +113,9 @@ describe("layer node", () => { ) await Effect.runPromise( Effect.map(Greeting, (item) => item.value).pipe( - Effect.provide(LayerNode.buildLayer(greeting, { tiers, replacements: [LayerNode.replace(other, replacement)] })), + Effect.provide( + build(LayerNode.group([greeting]), [LayerNode.replace(other, replacement)]), + ), ), ) expect(acquisitions).toBe(0) diff --git a/packages/opencode/test/git/git.test.ts b/packages/opencode/test/git/git.test.ts index c786513c63..6c99007d98 100644 --- a/packages/opencode/test/git/git.test.ts +++ b/packages/opencode/test/git/git.test.ts @@ -1,15 +1,17 @@ import { $ } from "bun" +import { buildNode } from "@/effect/build-node" import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" import { Effect } from "effect" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node-tree" import { Git } from "../../src/git" 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(buildNode(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 f2ee0b65d7..411585f35a 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-layer" import { SessionExecution } from "@opencode-ai/core/session/execution" import type { Provider } from "@/provider/provider" @@ -615,6 +616,7 @@ describe("session.compaction.create", () => { const v2 = yield* SessionV2.Service.use((svc) => svc.messages({ sessionID: info.id })).pipe( Effect.provide(SessionExecution.noopLayer), Effect.provide(SessionV2.defaultLayer), + 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 4706235f35..f8720c747e 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test" +import { buildNode } from "@/effect/build-node" import { SessionV1 } from "@opencode-ai/core/v1/session" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node-tree" import { SessionProjector } from "@opencode-ai/core/session/projector" import { Effect, Option } from "effect" import { Session as SessionNs } from "@/session/session" @@ -12,7 +14,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(buildNode(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 2e1864034e..e9c1503fd0 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -1,6 +1,8 @@ import { SessionV1 } from "@opencode-ai/core/v1/session" +import { buildNode } from "@/effect/build-node" 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-tree" import { EventV2Bridge } from "@/event-v2-bridge" import { expect } from "bun:test" import { tool } from "ai" @@ -180,9 +182,9 @@ const replacements = [ LayerNode.replace(SessionSummary.layer, summary), LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer({ experimentalEventSystem: true })), ] -const env = LayerNode.buildLayer( +const env = buildNode( LayerNode.group([root, LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] })]), - { replacements }, + replacements, ) const it = testEffect(env) @@ -207,9 +209,10 @@ const providerErrorLLM = Layer.succeed( ), }), ) -const providerErrorEnv = LayerNode.buildLayer(root, { - replacements: [...replacements, LayerNode.replace(LLM.layer, providerErrorLLM)], -}) +const providerErrorEnv = buildNode(root, [ + ...replacements, + LayerNode.replace(LLM.layer, providerErrorLLM), +]) const itProviderError = testEffect(providerErrorEnv) const fragmentFailureLLM = Layer.succeed( @@ -226,9 +229,10 @@ const fragmentFailureLLM = Layer.succeed( ), }), ) -const fragmentFailureEnv = LayerNode.buildLayer(root, { - replacements: [...replacements, LayerNode.replace(LLM.layer, fragmentFailureLLM)], -}) +const fragmentFailureEnv = buildNode(root, [ + ...replacements, + LayerNode.replace(LLM.layer, fragmentFailureLLM), +]) 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 8893c370f8..83c4b11f0c 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-layer" 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), ), ), @@ -656,6 +656,7 @@ noLLMServer.instance.skip( const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe( Effect.provide(SessionExecution.noopLayer), Effect.provide(SessionV2.defaultLayer), + 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 e53a6c1f18..305b47333b 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -1,10 +1,12 @@ import { describe, expect, test } from "bun:test" +import { buildNode } from "@/effect/build-node" import { SessionV1 } from "@opencode-ai/core/v1/session" import type { NamedError } from "@opencode-ai/core/util/error" import { APICallError } from "ai" import { setTimeout as sleep } from "node:timers/promises" import { Effect, Schedule, Schema } from "effect" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node-tree" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { SessionRetry } from "../../src/session/retry" import { MessageV2 } from "../../src/session/message-v2" @@ -16,7 +18,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(buildNode(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 313e5cf9c7..9a064a8dfc 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -11,6 +11,7 @@ * before tools run by hooking into start-step, but the AI SDK executes * tools internally during multi-step processing before emitting events. */ +import { buildNode } from "@/effect/build-node" import { expect } from "bun:test" import { Effect, Layer } from "effect" import { LayerNode } from "@opencode-ai/core/effect/layer-node" @@ -87,13 +88,11 @@ const root = LayerNode.group([ LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] }), ]) const it = testEffect( - LayerNode.buildLayer(root, { - replacements: [ + buildNode(root, [ LayerNode.replace(MCP.layer, mcp), LayerNode.replace(LSP.layer, lsp), LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer({ experimentalEventSystem: true })), - ], - }), + ]), ) const providerCfg = (url: string) => ({ diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index 548f0028c5..cc0942fb45 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-layer" 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 c541af5281..b115e28541 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -1,7 +1,9 @@ import { beforeEach, describe, expect } from "bun:test" +import { buildNode } from "@/effect/build-node" 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-tree" 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 +21,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 = buildNode(LayerNode.group([CrossSpawnSpawner.node])) const it = testEffect(env) const json = (req: Parameters[0], body: unknown, status = 200) => @@ -34,22 +36,15 @@ 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))], - }) + return buildNode(LayerNode.group([ShareNext.node, AccountRepo.node]), [ + LayerNode.replace(FetchHttpClient.layer, Layer.succeed(HttpClient.HttpClient, client)), + ]) } 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))] }, + return buildNode( + LayerNode.group([ShareNext.node, EventV2Bridge.node, Session.node, SessionProjector.node, AccountRepo.node, Database.node]), + [LayerNode.replace(FetchHttpClient.layer, Layer.succeed(HttpClient.HttpClient, client))], ) } diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 9c616f1a06..13264be66a 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -1,9 +1,11 @@ import { afterEach, describe, expect } from "bun:test" +import { buildNode } from "@/effect/build-node" import path from "path" 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-tree" import { ToolRegistry } from "@/tool/registry" import { Tool } from "@/tool/tool" import { disposeAllInstances, TestInstance } from "../fixture/fixture" @@ -54,11 +56,9 @@ const replacements = [ LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer()), ] -const it = testEffect(LayerNode.buildLayer(root, { replacements })) +const it = testEffect(buildNode(root, replacements)) const withBrokenPlugin = testEffect( - LayerNode.buildLayer(root, { - replacements: [...replacements, LayerNode.replace(Plugin.layer, brokenPluginLayer)], - }), + buildNode(root, [...replacements, LayerNode.replace(Plugin.layer, brokenPluginLayer)]), ) afterEach(async () => { diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index f12e9b625f..3a5e2e777e 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,11 +13,9 @@ 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" export const handlers = Layer.mergeAll( @@ -45,13 +37,4 @@ export const handlers = Layer.mergeAll( QuestionHandler, ReferenceHandler, ProjectCopyHandler, -).pipe( - Layer.provide(sessionLocationLayer), - Layer.provide(locationLayer), - Layer.provide(SessionV2.defaultLayer), - Layer.provide(SessionExecutionLocal.defaultLayer), - Layer.provide(PermissionSaved.defaultLayer), - Layer.provide(PtyTicket.defaultLayer), - Layer.provide(LocationServiceMap.layer), - Layer.provide(Credential.defaultLayer), ) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 7db252a9bf..d7c84dfaeb 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 } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" +import { ScopedNode } from "@opencode-ai/core/effect/scoped-node" +import { ScopedNodeBuild } from "@opencode-ai/core/effect/scoped-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 { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local" +import { LocationServiceMap, locationServices } from "@opencode-ai/core/location-layer" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { FetchHttpClient, HttpClient, HttpRouter, HttpServer } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Layer, Option } from "effect" import { Api } from "./api" @@ -10,6 +20,23 @@ 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, + SessionExecutionLocal.node, + PermissionSaved.node, + PtyTicket.node, + Credential.node, +]) +type ApplicationServices = typeof applicationServices extends LayerNode.Node + ? A + : never export function createRoutes(password?: string) { return makeRoutes( @@ -24,16 +51,19 @@ export function createEmbeddedRoutes() { } function makeRoutes(auth: Layer.Layer) { + const serviceLayer = ScopedNodeBuild.build(LayerNode.group([locationServices, applicationServices])) as Layer.Layer< + LocationServiceMap | ApplicationServices + > + 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 index 888e949efb..302a5f9d61 100644 --- a/specs/layer-node-tiers.md +++ b/specs/layer-node-tiers.md @@ -152,52 +152,28 @@ This differs from cross-tier uniqueness. Multiple implementations may be rebound Without a custom build function, a tier's sorted layers are combined with the default `Layer.provideMerge` behavior. -## Custom Tier Build Function +## Tier Layers -The optional third argument customizes how each tier's sorted layers are constructed: +`buildLayer` returns one independently composable layer per tier: ```ts -const appLayer = LayerNode.buildLayer( - root, - tiers, - (tier, layers) => { - const combined = LayerNode.combine(layers) +const built = LayerNode.buildLayer(root, { tiers }) - 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" }, - ), - ) - }, -) +const locationMap = buildLocationServiceMap(built.location) +const appLayer = locationMap.pipe(Layer.provideMerge(built.global)) ``` -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. +The application composition root owns lifecycle boundaries, `LayerMap` construction, and how tiers are connected. Input nodes remain requirements of their tier layer until that root supplies them. ## Replacements Tests and alternate runtimes may replace a specific layer implementation by exact object identity: ```ts -const layer = LayerNode.buildLayer( - root, +const layer = LayerNode.buildLayer(root, { tiers, - buildTier, - [LayerNode.replace(Config.layer, testConfigLayer)], -) + replacements: [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. @@ -212,7 +188,7 @@ locationTier .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. +The location tier contains only location implementations. Global dependencies are connected after the application creates its fresh or `LayerMap` boundary, so global services remain shared. ## Responsibilities