From 07e5ea93670c3ecaf2b82989dd8d2430e3a34d05 Mon Sep 17 00:00:00 2001 From: James Long Date: Tue, 9 Jun 2026 15:31:31 -0400 Subject: [PATCH] feat(opencode): add typed application layer graph (#31531) --- packages/core/src/cross-spawn-spawner.ts | 3 + packages/core/src/database/database.ts | 3 + .../core/src/effect/layer-node-platform.ts | 12 + packages/core/src/effect/layer-node.ts | 95 ++++++++ packages/core/src/event.ts | 2 + packages/core/src/filesystem/ripgrep.ts | 3 + packages/core/src/filesystem/search.ts | 2 + packages/core/src/fs-util.ts | 3 + packages/core/src/git.ts | 2 + packages/core/src/global.ts | 2 + packages/core/src/models-dev.ts | 3 + packages/core/src/npm.ts | 3 + packages/core/src/process.ts | 2 + packages/core/src/project.ts | 2 + packages/core/src/project/copy.ts | 2 + packages/core/src/pty/ticket.ts | 2 + packages/core/src/session/projector.ts | 2 + packages/core/src/util/effect-flock.ts | 2 + packages/opencode/src/account/account.ts | 4 + packages/opencode/src/account/repo.ts | 3 + packages/opencode/src/agent/agent.ts | 3 + packages/opencode/src/auth/index.ts | 3 + packages/opencode/src/background/job.ts | 3 + packages/opencode/src/command/index.ts | 3 + packages/opencode/src/config/config.ts | 4 + .../opencode/src/control-plane/workspace.ts | 14 ++ packages/opencode/src/effect/runtime-flags.ts | 3 + packages/opencode/src/env/index.ts | 3 + packages/opencode/src/event-v2-bridge.ts | 3 + packages/opencode/src/format/index.ts | 3 + packages/opencode/src/git/index.ts | 3 + packages/opencode/src/image/image.ts | 3 + packages/opencode/src/installation/index.ts | 4 + packages/opencode/src/lsp/lsp.ts | 4 + packages/opencode/src/mcp/auth.ts | 3 + packages/opencode/src/mcp/index.ts | 3 + packages/opencode/src/permission/index.ts | 3 + packages/opencode/src/plugin/index.ts | 3 + packages/opencode/src/project/bootstrap.ts | 13 ++ .../opencode/src/project/instance-store.ts | 4 + packages/opencode/src/project/project.ts | 12 + packages/opencode/src/project/vcs.ts | 3 + packages/opencode/src/provider/auth.ts | 3 + packages/opencode/src/provider/provider.ts | 11 + packages/opencode/src/question/index.ts | 3 + packages/opencode/src/session/compaction.ts | 12 + packages/opencode/src/session/instruction.ts | 4 + packages/opencode/src/session/llm.ts | 13 ++ packages/opencode/src/session/processor.ts | 17 ++ packages/opencode/src/session/prompt.ts | 30 +++ packages/opencode/src/session/revert.ts | 10 + packages/opencode/src/session/run-state.ts | 3 + packages/opencode/src/session/session.ts | 3 + packages/opencode/src/session/status.ts | 3 + packages/opencode/src/session/summary.ts | 3 + packages/opencode/src/session/system.ts | 3 + packages/opencode/src/session/todo.ts | 3 + packages/opencode/src/share/session.ts | 3 + packages/opencode/src/share/share-next.ts | 12 + packages/opencode/src/skill/discovery.ts | 4 + packages/opencode/src/skill/index.ts | 10 + packages/opencode/src/snapshot/index.ts | 3 + packages/opencode/src/storage/storage.ts | 3 + packages/opencode/src/tool/registry.ts | 27 +++ packages/opencode/src/tool/truncate.ts | 3 + packages/opencode/src/worktree/index.ts | 12 + .../test/effect/app-graph-types.test.ts | 101 +++++++++ .../opencode/test/effect/app-graph.test.ts | 208 ++++++++++++++++++ 68 files changed, 759 insertions(+) create mode 100644 packages/core/src/effect/layer-node-platform.ts create mode 100644 packages/core/src/effect/layer-node.ts create mode 100644 packages/opencode/test/effect/app-graph-types.test.ts create mode 100644 packages/opencode/test/effect/app-graph.test.ts diff --git a/packages/core/src/cross-spawn-spawner.ts b/packages/core/src/cross-spawn-spawner.ts index ad8d4126d..d6e0f9f95 100644 --- a/packages/core/src/cross-spawn-spawner.ts +++ b/packages/core/src/cross-spawn-spawner.ts @@ -24,6 +24,8 @@ 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 { filesystem, path } from "./effect/layer-node-platform" const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err))) @@ -501,5 +503,6 @@ export const layer: Layer.Layer @@ -58,3 +59,5 @@ export const defaultLayer = Layer.unwrap( return layerFromPath(path()) }), ).pipe(Layer.provide(Global.defaultLayer)) + +export const node = LayerNode.make(layerFromPath(path()), []) diff --git a/packages/core/src/effect/layer-node-platform.ts b/packages/core/src/effect/layer-node-platform.ts new file mode 100644 index 000000000..2e63d2958 --- /dev/null +++ b/packages/core/src/effect/layer-node-platform.ts @@ -0,0 +1,12 @@ +import { NodeFileSystem, NodePath } from "@effect/platform-node" +import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route" +import { FetchHttpClient } from "effect/unstable/http" +import { LayerNode } from "./layer-node" + +export const filesystem = LayerNode.make(NodeFileSystem.layer, []) +export const path = LayerNode.make(NodePath.layer, []) +export const httpClient = LayerNode.make(FetchHttpClient.layer, []) +export const requestExecutor = LayerNode.make(RequestExecutor.layer, [httpClient]) +export const llmClient = LayerNode.make(LLMClient.layer, [requestExecutor]) + +export * as LayerNodePlatform from "./layer-node-platform" diff --git a/packages/core/src/effect/layer-node.ts b/packages/core/src/effect/layer-node.ts new file mode 100644 index 000000000..e28733360 --- /dev/null +++ b/packages/core/src/effect/layer-node.ts @@ -0,0 +1,95 @@ +import { Layer } from "effect" + +type RuntimeLayer = Layer.Layer +type AnyNode = Node +type NodeList = readonly [] | readonly [AnyNode, ...AnyNode[]] +type Output = [Item] extends [never] ? never : Item extends Node ? A : never +type Error = [Item] extends [never] ? never : Item extends Node ? E : never +type Missing = Exclude> +type CheckDependencies = [ + Missing, Dependencies>, +] extends [never] + ? unknown + : { readonly "Missing dependencies": Missing, Dependencies> } +declare const $OutputType: unique symbol +declare const $ErrorType: unique symbol + +export type Node = { + readonly kind: "layer" | "group" + readonly implementation?: Layer.Any + readonly dependencies: readonly AnyNode[] + readonly [$OutputType]?: () => A + readonly [$ErrorType]?: () => E +} + +export function make( + implementation: Implementation, + dependencies: Items & CheckDependencies>, +): Node, Layer.Error | Error> { + return { kind: "layer", implementation: implementation as Layer.Any, dependencies } +} + +export function group( + dependencies: Items, +): Node, Error> { + return { kind: "group", dependencies } +} + +export type Replacement = { + readonly source: Node + readonly replacement: Node +} + +type CheckReplacementErrors = [Exclude] extends [never] + ? unknown + : { readonly "New replacement errors": Exclude } + +export function replace( + source: Node, + replacement: Node, E2> & CheckReplacementErrors>, +): Replacement { + return { source, replacement } +} + +export function buildLayer(node: Node, options?: { readonly replacements?: readonly Replacement[] }) { + const replacements = new Map(options?.replacements?.map((item) => [item.source, item.replacement])) + const cache = new Map() + const visiting = new Set() + const stack: AnyNode[] = [] + const ids = new Map() + + const visit = (input: AnyNode): RuntimeLayer => { + const node = replacements.get(input) ?? input + const cached = cache.get(node) + if (cached) return cached + if (visiting.has(node)) { + const start = stack.indexOf(node) + const cycle = [...stack.slice(start), node].map((item) => `${item.kind}#${ids.get(item)}`).join(" -> ") + throw new Error(`Cycle detected in app graph: ${cycle}`) + } + if (!ids.has(node)) ids.set(node, ids.size + 1) + visiting.add(node) + stack.push(node) + try { + const dependencies = node.dependencies.map(visit) + const nonEmpty = dependencies as [RuntimeLayer, ...RuntimeLayer[]] + const result = + node.kind === "group" + ? dependencies.length === 0 + ? Layer.empty + : Layer.mergeAll(...nonEmpty) + : dependencies.length === 0 + ? (node.implementation as RuntimeLayer) + : Layer.provide(node.implementation as RuntimeLayer, nonEmpty) + cache.set(node, result) + return result + } finally { + stack.pop() + visiting.delete(node) + } + } + + return visit(node) as unknown as Layer.Layer +} + +export * as LayerNode from "./layer-node" diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 476803f4a..7a33eedc4 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -7,6 +7,7 @@ import { EventSequenceTable, EventTable } from "./event/sql" import { Location } from "./location" import { externalID, type ExternalID, NonNegativeInt, withStatics } from "./schema" import { Identifier } from "./util/identifier" +import { LayerNode } from "./effect/layer-node" import { isDeepStrictEqual } from "node:util" export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe( @@ -674,5 +675,6 @@ export const layerWith = (options?: LayerOptions) => ) export const layer = layerWith() +export const node = LayerNode.make(layer, [Database.node]) export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) diff --git a/packages/core/src/filesystem/ripgrep.ts b/packages/core/src/filesystem/ripgrep.ts index e0bc1c7e1..036d0fb3a 100644 --- a/packages/core/src/filesystem/ripgrep.ts +++ b/packages/core/src/filesystem/ripgrep.ts @@ -11,6 +11,8 @@ import { CrossSpawnSpawner } from "../cross-spawn-spawner" import { Global } from "../global" import { NonNegativeInt } from "../schema" import { which } from "../util/which" +import { LayerNode } from "../effect/layer-node" +import { httpClient } from "../effect/layer-node-platform" const VERSION = "15.1.0" const PLATFORM = { @@ -480,5 +482,6 @@ export const defaultLayer = layer.pipe( Layer.provide(FSUtil.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), ) +export const node = LayerNode.make(layer, [FSUtil.node, CrossSpawnSpawner.node, httpClient]) export * as Ripgrep from "./ripgrep" diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index 8128bab8d..8ffa0318a 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -8,6 +8,7 @@ import { serviceUse } from "../effect/service-use" import { makeRuntime } from "../effect/runtime" import { Fff } from "#fff" import { Ripgrep } from "./ripgrep" +import { LayerNode } from "../effect/layer-node" const root = path.join(Global.Path.cache, "fff") @@ -539,6 +540,7 @@ export const defaultLayer: Layer.Layer = layer.pipe( Layer.provide(Ripgrep.defaultLayer), Layer.provide(FSUtil.defaultLayer), ) +export const node = LayerNode.make(layer, [FSUtil.node, Ripgrep.node]) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/core/src/fs-util.ts b/packages/core/src/fs-util.ts index 82ddd4048..24263cbad 100644 --- a/packages/core/src/fs-util.ts +++ b/packages/core/src/fs-util.ts @@ -7,6 +7,8 @@ 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 { filesystem } from "./effect/layer-node-platform" export namespace FSUtil { export class FileSystemError extends Schema.TaggedErrorClass()("FileSystemError", { @@ -194,6 +196,7 @@ export namespace FSUtil { ) export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer)) + export const node = LayerNode.make(layer, [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 fd35ad970..0041c3353 100644 --- a/packages/core/src/git.ts +++ b/packages/core/src/git.ts @@ -6,6 +6,7 @@ import { ChildProcess } from "effect/unstable/process" import { AbsolutePath } from "./schema" import { FSUtil } from "./fs-util" import { AppProcess } from "./process" +import { LayerNode } from "./effect/layer-node" export interface Repo { /** @@ -400,6 +401,7 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer)) +export const node = LayerNode.make(layer, [FSUtil.node, AppProcess.node]) export interface Result { readonly exitCode: number diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index 5f9799c25..2a0ac95d1 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -5,6 +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" const app = "opencode" const data = path.join(xdgData!, app) @@ -76,6 +77,7 @@ export const layer = Layer.effect( ) export const defaultLayer = layer +export const node = LayerNode.make(layer, []) export const layerWith = (input: Partial) => Layer.effect( diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 236fcc5eb..3f9f67037 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -8,6 +8,8 @@ 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 { httpClient } from "./effect/layer-node-platform" export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"]) export type CatalogModelStatus = typeof CatalogModelStatus.Type @@ -246,5 +248,6 @@ export const defaultLayer = layer.pipe( Layer.provide(FSUtil.defaultLayer), Layer.provide(EventV2.defaultLayer), ) +export const node = LayerNode.make(layer, [FSUtil.node, EventV2.node, httpClient]) export * as ModelsDev from "./models-dev" diff --git a/packages/core/src/npm.ts b/packages/core/src/npm.ts index 759e04870..f3398e839 100644 --- a/packages/core/src/npm.ts +++ b/packages/core/src/npm.ts @@ -7,6 +7,8 @@ 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 { filesystem } from "./effect/layer-node-platform" import { makeRuntime } from "./effect/runtime" import { NpmConfig } from "./npm-config" @@ -250,6 +252,7 @@ export const defaultLayer = layer.pipe( Layer.provide(Global.layer), Layer.provide(NodeFileSystem.layer), ) +export const node = LayerNode.make(layer, [FSUtil.node, Global.node, filesystem, EffectFlock.node]) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/core/src/process.ts b/packages/core/src/process.ts index 4555b2801..44418d74c 100644 --- a/packages/core/src/process.ts +++ b/packages/core/src/process.ts @@ -3,6 +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" export class AppProcessError extends Schema.TaggedErrorClass()("AppProcessError", { command: Schema.String, @@ -230,5 +231,6 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer)) +export const node = LayerNode.make(layer, [CrossSpawnSpawner.node]) export * as AppProcess from "./process" diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index 4b3977310..a7589c121 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -8,6 +8,7 @@ import { AbsolutePath, withStatics } from "./schema" import { FSUtil } from "./fs-util" import { Database } from "./database/database" import { Git } from "./git" +import { LayerNode } from "./effect/layer-node" import { Hash } from "./util/hash" import { ProjectDirectoryTable } from "./project/sql" @@ -159,3 +160,4 @@ export const defaultLayer = layer.pipe( Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer), ) +export const node = LayerNode.make(layer, [Database.node, FSUtil.node, Git.node]) diff --git a/packages/core/src/project/copy.ts b/packages/core/src/project/copy.ts index 4c5743d13..de2beda98 100644 --- a/packages/core/src/project/copy.ts +++ b/packages/core/src/project/copy.ts @@ -8,6 +8,7 @@ import { FSUtil } from "../fs-util" import { Git } from "../git" import { Database } from "../database/database" import { EventV2 } from "../event" +import { LayerNode } from "../effect/layer-node" import { Project } from "../project" import { ProjectDirectoryTable } from "./sql" import { makeStrategies } from "./copy-strategies" @@ -275,3 +276,4 @@ export const defaultLayer = layer.pipe( Layer.provide(Git.defaultLayer), Layer.provide(EventV2.defaultLayer), ) +export const node = LayerNode.make(layer, [FSUtil.node, Git.node, EventV2.node, Database.node]) diff --git a/packages/core/src/pty/ticket.ts b/packages/core/src/pty/ticket.ts index 1d2452cda..c625390be 100644 --- a/packages/core/src/pty/ticket.ts +++ b/packages/core/src/pty/ticket.ts @@ -4,6 +4,7 @@ import { WorkspaceV2 } from "../workspace" import { PositiveInt } from "../schema" import { PtyID } from "./schema" import { Cache, Context, Duration, Effect, Layer, Schema } from "effect" +import { LayerNode } from "../effect/layer-node" const DEFAULT_TTL = Duration.seconds(60) const CAPACITY = 10_000 @@ -56,3 +57,4 @@ export const make = (ttl: Duration.Input = DEFAULT_TTL) => export const layer = Layer.effect(Service, make()) export const defaultLayer = layer +export const node = LayerNode.make(layer, []) diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index e22da3be5..caf63de78 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -4,6 +4,7 @@ import { and, desc, eq, 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 { SessionEvent } from "./event" import { SessionV1 } from "../v1/session" import { WorkspaceTable } from "../control-plane/workspace.sql" @@ -447,3 +448,4 @@ export const layer = Layer.effectDiscard( ) export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer)) +export const node = LayerNode.make(layer, [EventV2.node, Database.node]) diff --git a/packages/core/src/util/effect-flock.ts b/packages/core/src/util/effect-flock.ts index 64a1b6f7a..2ba5ef0d7 100644 --- a/packages/core/src/util/effect-flock.ts +++ b/packages/core/src/util/effect-flock.ts @@ -6,6 +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 { Hash } from "./hash" export namespace EffectFlock { @@ -280,4 +281,5 @@ export namespace EffectFlock { ) export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.layer)) + export const node = LayerNode.make(layer, [Global.node, FSUtil.node]) } diff --git a/packages/opencode/src/account/account.ts b/packages/opencode/src/account/account.ts index 9d9f7e4a2..948eb3c06 100644 --- a/packages/opencode/src/account/account.ts +++ b/packages/opencode/src/account/account.ts @@ -1,3 +1,5 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { @@ -456,4 +458,6 @@ export const layer: Layer.Layer = {}) => export const defaultLayer = Service.defaultLayer.pipe(Layer.orDie) +export const node = LayerNode.make(defaultLayer, []) + export * as RuntimeFlags from "./runtime-flags" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" diff --git a/packages/opencode/src/env/index.ts b/packages/opencode/src/env/index.ts index 89ccf7d0f..5f85dc6f2 100644 --- a/packages/opencode/src/env/index.ts +++ b/packages/opencode/src/env/index.ts @@ -1,3 +1,4 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Context, Effect, Layer } from "effect" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { InstanceState } from "@/effect/instance-state" @@ -37,4 +38,6 @@ export const layer = Layer.effect( export const defaultLayer = layer +export const node = LayerNode.make(layer, []) + export * as Env from "." diff --git a/packages/opencode/src/event-v2-bridge.ts b/packages/opencode/src/event-v2-bridge.ts index 6a31b42b2..14a8053f9 100644 --- a/packages/opencode/src/event-v2-bridge.ts +++ b/packages/opencode/src/event-v2-bridge.ts @@ -1,5 +1,6 @@ // Opencode publish boundary for core events. Attach routed instance location // so direct EventV2 consumers can isolate directory/workspace streams. +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { GlobalBus } from "@/bus/global" import { EventV2 } from "@opencode-ai/core/event" @@ -73,4 +74,6 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer)) +export const node = LayerNode.make(layer, [EventV2.node]) + export * as EventV2Bridge from "./event-v2-bridge" diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index 0b6c0a8dd..e323fcc24 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -1,3 +1,4 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer, Context, Schema } from "effect" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { ChildProcess } from "effect/unstable/process" @@ -199,4 +200,6 @@ export const defaultLayer = layer.pipe( Layer.provide(RuntimeFlags.defaultLayer), ) +export const node = LayerNode.make(layer, [Config.node, AppProcess.node, RuntimeFlags.node]) + export * as Format from "." diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts index 5e76b7f73..7a37b1cb8 100644 --- a/packages/opencode/src/git/index.ts +++ b/packages/opencode/src/git/index.ts @@ -1,3 +1,4 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { AppProcess } from "@opencode-ai/core/process" import { Effect, Layer, Context, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" @@ -344,4 +345,6 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(AppProcess.defaultLayer)) +export const node = LayerNode.make(layer, [AppProcess.node]) + export * as Git from "." diff --git a/packages/opencode/src/image/image.ts b/packages/opencode/src/image/image.ts index e85a93bbb..91c8955e1 100644 --- a/packages/opencode/src/image/image.ts +++ b/packages/opencode/src/image/image.ts @@ -1,3 +1,4 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Config } from "@/config/config" import { SessionV1 } from "@opencode-ai/core/v1/session" import type { MessageV2 } from "@/session/message-v2" @@ -168,4 +169,6 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) +export const node = LayerNode.make(layer, [Config.node]) + export * as Image from "./image" diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 2407d91cd..0ed10dc32 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -1,3 +1,5 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" import { Effect, Layer, Schema, Context, Stream } from "effect" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" @@ -343,4 +345,6 @@ export const latest = (...args: Parameters) => runPromise(( export const method = () => runPromise((s) => s.method()) export const upgrade = (...args: Parameters) => runPromise((s) => s.upgrade(...args)) +export const node = LayerNode.make(layer, [httpClient, AppProcess.node]) + export * as Installation from "." diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 3c620d456..0e7cf82d9 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -1,3 +1,5 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { FSUtil } from "@opencode-ai/core/fs-util" import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2 } from "@opencode-ai/core/event" import * as LSPClient from "./client" @@ -504,4 +506,6 @@ export const defaultLayer = layer.pipe( export * as Diagnostic from "./diagnostic" +export const node = LayerNode.make(layer, [Config.node, RuntimeFlags.node, FSUtil.node, EventV2Bridge.node]) + export * as LSP from "./lsp" diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index e059a25be..be03760c5 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -1,3 +1,4 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import path from "path" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { Global } from "@opencode-ai/core/global" @@ -168,4 +169,6 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(EffectFlock.defaultLayer), Layer.provide(FSUtil.defaultLayer)) +export const node = LayerNode.make(layer, [FSUtil.node, EffectFlock.node]) + export * as McpAuth from "./auth" diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 7b9f4c4c1..840939758 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -1,3 +1,4 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { dynamicTool, type Tool, jsonSchema, type JSONSchema7 } from "ai" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { serviceUse } from "@opencode-ai/core/effect/service-use" @@ -1003,4 +1004,6 @@ export const defaultLayer = layer.pipe( Layer.provide(FSUtil.defaultLayer), ) +export const node = LayerNode.make(layer, [CrossSpawnSpawner.node, McpAuth.node, EventV2Bridge.node, Config.node]) + export * as MCP from "." diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index dd67e2bc2..cd1f935ad 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -1,3 +1,4 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ConfigPermissionV1 } from "@opencode-ai/core/v1/config/permission" import { InstanceState } from "@/effect/instance-state" import { Wildcard } from "@opencode-ai/core/util/wildcard" @@ -224,4 +225,6 @@ export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set = layer.pipe( ]), ) +export const node = LayerNode.make(layer, [ + Config.node, + Format.node, + LSP.node, + Plugin.node, + Project.node, + Search.node, + ShareNext.node, + Snapshot.node, + Vcs.node, +]) + export * as InstanceBootstrap from "./bootstrap" diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 145294a0e..aab8f60c8 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -1,3 +1,4 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { GlobalBus } from "@/bus/global" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { WorkspaceContext } from "@/control-plane/workspace-context" @@ -7,6 +8,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect" import { type InstanceContext } from "./instance-context" import { InstanceBootstrap } from "./bootstrap-service" +import { InstanceBootstrap as InstanceBootstrapGraph } from "./bootstrap" import * as Project from "./project" export interface LoadInput { @@ -202,4 +204,6 @@ export const layer: Layer.Layer layer.pipe(Layer.provide(Auth.defaultLayer), Layer.provide(Plugin.defaultLayer)), ) +export const node = LayerNode.make(layer, [Auth.node, Plugin.node]) + export * as ProviderAuth from "./auth" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 0ff492421..86515068d 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1,3 +1,4 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import os from "os" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import fuzzysort from "fuzzysort" @@ -1948,4 +1949,14 @@ export function parseModel(model: string) { } } +export const node = LayerNode.make(layer, [ + FSUtil.node, + Config.node, + Auth.node, + Env.node, + Plugin.node, + ModelsDev.node, + RuntimeFlags.node, +]) + export * as Provider from "./provider" diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 29fae7e4a..61bdc40ee 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -1,3 +1,4 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Deferred, Effect, Layer, Schema, Context } from "effect" import { InstanceState } from "@/effect/instance-state" import { SessionID, MessageID } from "@/session/schema" @@ -223,4 +224,6 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer)) +export const node = LayerNode.make(layer, [EventV2Bridge.node]) + export * as Question from "." diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 086ae8360..c7ac963c6 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -1,3 +1,4 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { SessionV1 } from "@opencode-ai/core/v1/session" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { Session } from "./session" @@ -605,4 +606,15 @@ export const defaultLayer = Layer.suspend(() => ), ) +export const node = LayerNode.make(layer, [ + Config.node, + Session.node, + Agent.node, + Plugin.node, + SessionProcessor.node, + Provider.node, + EventV2Bridge.node, + RuntimeFlags.node, +]) + export * as SessionCompaction from "./compaction" diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 5a145a212..38ac55bbb 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -1,3 +1,5 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" import path from "path" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Effect, Layer, Context } from "effect" @@ -234,4 +236,6 @@ export function loaded(messages: SessionV1.WithParts[]) { return extract(messages) } +export const node = LayerNode.make(layer, [Config.node, FSUtil.node, Global.node, RuntimeFlags.node, httpClient]) + export * as Instruction from "./instruction" diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index d8856e218..adacfc431 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -1,3 +1,5 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { llmClient } from "@opencode-ai/core/effect/layer-node-platform" import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { Provider } from "@/provider/provider" import { SessionV1 } from "@opencode-ai/core/v1/session" @@ -399,4 +401,15 @@ export const defaultLayer = Layer.suspend(() => export const hasToolCalls = LLMRequestPrep.hasToolCalls +export const node = LayerNode.make(layer, [ + Auth.node, + Config.node, + Provider.node, + Plugin.node, + Permission.node, + EventV2Bridge.node, + llmClient, + RuntimeFlags.node, +]) + export * as LLM from "./llm" diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 2a19f393b..255431590 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1,3 +1,4 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { Image } from "@/image/image" import { SessionV1 } from "@opencode-ai/core/v1/session" @@ -1064,4 +1065,20 @@ export const defaultLayer = Layer.suspend(() => ), ) +export const node = LayerNode.make(layer, [ + Session.node, + Config.node, + Snapshot.node, + Agent.node, + LLM.node, + Permission.node, + Plugin.node, + SessionSummary.node, + SessionStatus.node, + Image.node, + EventV2Bridge.node, + RuntimeFlags.node, + Database.node, +]) + export * as SessionProcessor from "./processor" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 20940e370..2a58cd425 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1,3 +1,4 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { PermissionV1 } from "@opencode-ai/core/v1/permission" import path from "path" import { SessionV1 } from "@opencode-ai/core/v1/session" @@ -1671,4 +1672,33 @@ const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi const placeholderRegex = /\$(\d+)/g const quoteTrimRegex = /^["']|["']$/g +export const node = LayerNode.make(layer, [ + SessionStatus.node, + Session.node, + Agent.node, + Provider.node, + SessionProcessor.node, + SessionCompaction.node, + Plugin.node, + Command.node, + Config.node, + Permission.node, + FSUtil.node, + MCP.node, + LSP.node, + ToolRegistry.node, + Truncate.node, + Image.node, + CrossSpawnSpawner.node, + Instruction.node, + SessionRunState.node, + SessionRevert.node, + SessionSummary.node, + SystemPrompt.node, + LLM.node, + EventV2Bridge.node, + RuntimeFlags.node, + Database.node, +]) + export * as SessionPrompt from "./prompt" diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index df890d718..04631e4ec 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -1,3 +1,4 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer, Context, Schema } from "effect" import { SessionV1 } from "@opencode-ai/core/v1/session" import { EventV2Bridge } from "@/event-v2-bridge" @@ -147,4 +148,13 @@ export const defaultLayer = Layer.suspend(() => ), ) +export const node = LayerNode.make(layer, [ + Session.node, + Snapshot.node, + Storage.node, + EventV2Bridge.node, + SessionSummary.node, + SessionRunState.node, +]) + export * as SessionRevert from "./revert" diff --git a/packages/opencode/src/session/run-state.ts b/packages/opencode/src/session/run-state.ts index 9ac171f56..9c8519161 100644 --- a/packages/opencode/src/session/run-state.ts +++ b/packages/opencode/src/session/run-state.ts @@ -1,3 +1,4 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { InstanceState } from "@/effect/instance-state" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Runner } from "@/effect/runner" @@ -150,4 +151,6 @@ function busyError(sessionID: SessionID) { return new Session.BusyError({ sessionID }) } +export const node = LayerNode.make(layer, [BackgroundJob.node, SessionStatus.node]) + export * as SessionRunState from "./run-state" diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 7abbba0b8..038ec9aa0 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -1,3 +1,4 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { Slug } from "@opencode-ai/core/util/slug" import { SessionV1 } from "@opencode-ai/core/v1/session" @@ -1113,4 +1114,6 @@ export function* listGlobal(input?: { } } +export const node = LayerNode.make(layer, [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node]) + export * as Session from "./session" diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index a7a6c5f87..68758ea6a 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -1,3 +1,4 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { InstanceState } from "@/effect/instance-state" import { SessionID } from "./schema" import { NonNegativeInt } from "@opencode-ai/core/schema" @@ -91,4 +92,6 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer)) +export const node = LayerNode.make(layer, [EventV2Bridge.node]) + export * as SessionStatus from "./status" diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 29e39e985..370870935 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -1,3 +1,4 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer, Context, Schema } from "effect" import { SessionV1 } from "@opencode-ai/core/v1/session" import { EventV2Bridge } from "@/event-v2-bridge" @@ -159,4 +160,6 @@ export const DiffInput = Schema.Struct({ }) export type DiffInput = Schema.Schema.Type +export const node = LayerNode.make(layer, [Session.node, Snapshot.node, EventV2Bridge.node, Config.node]) + export * as SessionSummary from "./summary" diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 06c71fa7d..cee669d14 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -1,3 +1,4 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Context, Effect, Layer } from "effect" import { InstanceState } from "@/effect/instance-state" @@ -81,4 +82,6 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(Skill.defaultLayer)) +export const node = LayerNode.make(layer, [Skill.node]) + export * as SystemPrompt from "./system" diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index 37598f9d5..6e9eeba62 100644 --- a/packages/opencode/src/session/todo.ts +++ b/packages/opencode/src/session/todo.ts @@ -1,3 +1,4 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { SessionID } from "./schema" import { Effect, Layer, Context, Schema } from "effect" import { Database } from "@opencode-ai/core/database/database" @@ -84,4 +85,6 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(Database.defaultLayer)) +export const node = LayerNode.make(layer, [EventV2Bridge.node, Database.node]) + export * as Todo from "./todo" diff --git a/packages/opencode/src/share/session.ts b/packages/opencode/src/share/session.ts index b27bc728a..776e8aa1c 100644 --- a/packages/opencode/src/share/session.ts +++ b/packages/opencode/src/share/session.ts @@ -1,3 +1,4 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Session } from "@/session/session" import { SessionID } from "@/session/schema" import { Effect, Layer, Scope, Context } from "effect" @@ -55,4 +56,6 @@ export const defaultLayer = layer.pipe( Layer.provide(RuntimeFlags.defaultLayer), ) +export const node = LayerNode.make(layer, [Config.node, Session.node, ShareNext.node, RuntimeFlags.node]) + export * as SessionShare from "./session" diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 259e0d717..90c2eafac 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -1,3 +1,5 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" import type * as SDK from "@opencode-ai/sdk/v2" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { Effect, Exit, Layer, Option, Schema, Scope, Context, Stream } from "effect" @@ -370,4 +372,14 @@ export const defaultLayer = layer.pipe( Layer.provide(Session.defaultLayer), ) +export const node = LayerNode.make(layer, [ + Account.node, + EventV2Bridge.node, + Config.node, + Database.node, + httpClient, + Provider.node, + Session.node, +]) + export * as ShareNext from "./share-next" diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts index 8537d3867..0495bc637 100644 --- a/packages/opencode/src/skill/discovery.ts +++ b/packages/opencode/src/skill/discovery.ts @@ -1,3 +1,5 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient, path } from "@opencode-ai/core/effect/layer-node-platform" import { NodePath } from "@effect/platform-node" import { Effect, Layer, Path, Schema, Context } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" @@ -102,4 +104,6 @@ export const defaultLayer: Layer.Layer = layer.pipe( Layer.provide(NodePath.layer), ) +export const node = LayerNode.make(layer, [FSUtil.node, path, httpClient]) + export * as Discovery from "./discovery" diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 14bdf1289..b8bd6bef6 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -1,3 +1,4 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import path from "path" import { pathToFileURL } from "url" import { Effect, Layer, Context, Schema } from "effect" @@ -353,4 +354,13 @@ export function fmt(list: Info[], opts: { verbose: boolean }) { ].join("\n") } +export const node = LayerNode.make(layer, [ + Discovery.node, + Config.node, + EventV2Bridge.node, + FSUtil.node, + Global.node, + RuntimeFlags.node, +]) + export * as Skill from "." diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index fdb4abf6c..a9b3411cc 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -1,3 +1,4 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Duration, Effect, Layer, Schedule, Schema, Semaphore, Context } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { formatPatch, structuredPatch } from "diff" @@ -756,4 +757,6 @@ export const defaultLayer = layer.pipe( Layer.provide(Config.defaultLayer), ) +export const node = LayerNode.make(layer, [FSUtil.node, AppProcess.node, Config.node]) + export * as Snapshot from "." diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index 5ba34780c..5449865fb 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -1,3 +1,4 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import path from "path" import { Global } from "@opencode-ai/core/global" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -323,4 +324,6 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer)) +export const node = LayerNode.make(layer, [FSUtil.node, Git.node]) + export * as Storage from "./storage" diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 8c9916125..517c95526 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -1,3 +1,6 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" +import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" import { PlanExitTool } from "./plan" import { Session } from "@/session/session" import { QuestionTool } from "./question" @@ -437,4 +440,28 @@ function isJsonSchemaObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } +export const node = LayerNode.make(layer, [ + Config.node, + Plugin.node, + Question.node, + Todo.node, + Agent.node, + Skill.node, + Session.node, + BackgroundJob.node, + Provider.node, + LSP.node, + Instruction.node, + FSUtil.node, + EventV2Bridge.node, + httpClient, + CrossSpawnSpawner.node, + Ripgrep.node, + Search.node, + Format.node, + Truncate.node, + RuntimeFlags.node, + Database.node, +]) + export * as ToolRegistry from "./registry" diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index 2037b798c..1815643d0 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -1,3 +1,4 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { NodePath } from "@effect/platform-node" import { Cause, Duration, Effect, Layer, Option, Schedule, Context } from "effect" import path from "path" @@ -152,4 +153,6 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(NodePath.layer)) +export const node = LayerNode.make(layer, [FSUtil.node]) + export * as Truncate from "./truncate" diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 9e3ad17bc..8ee485b81 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -1,3 +1,5 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { path } from "@opencode-ai/core/effect/layer-node-platform" import { Global } from "@opencode-ai/core/global" import { InstanceLayer } from "@/project/instance-layer" import { InstanceStore } from "@/project/instance-store" @@ -639,4 +641,14 @@ export const appLayer = layer.pipe( export const defaultLayer = appLayer.pipe(Layer.provide(InstanceLayer.layer)) +export const node = LayerNode.make(layer, [ + FSUtil.node, + path, + AppProcess.node, + Git.node, + Project.node, + InstanceStore.node, + Database.node, +]) + export * as Worktree from "." diff --git a/packages/opencode/test/effect/app-graph-types.test.ts b/packages/opencode/test/effect/app-graph-types.test.ts new file mode 100644 index 000000000..7ef3d0b6c --- /dev/null +++ b/packages/opencode/test/effect/app-graph-types.test.ts @@ -0,0 +1,101 @@ +import { test } from "bun:test" +import { Context, Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" + +class A extends Context.Service()("test/A") {} +class B extends Context.Service()("test/B") {} +class C extends Context.Service()("test/C") {} +class LayerError { + readonly _tag = "LayerError" +} +class NotFoundError { + readonly _tag = "NotFoundError" +} +class DiskError { + readonly _tag = "DiskError" +} +class NetworkError { + readonly _tag = "NetworkError" +} + +const aImplementation = Layer.succeed(A, A.of({ value: "a" })) +const bImplementation = Layer.effect(B, Effect.gen(function* () { + yield* A + return B.of({ value: "b" }) +})) +const cImplementation = Layer.effect(C, Effect.gen(function* () { + yield* A + yield* B + return C.of({ value: "c" }) +})) +const failingAImplementation = Layer.effect(A, Effect.fail(new LayerError())) +const notFoundAImplementation = Layer.effect(A, Effect.fail(new NotFoundError())) +const diskAImplementation = Layer.effect(A, Effect.fail(new DiskError())) +const networkAImplementation = Layer.effect(A, Effect.fail(new NetworkError())) +const notFoundOrDiskAImplementation = Layer.effect( + A, + Effect.fail(new NotFoundError() as NotFoundError | DiskError), +) + +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false +type Assert = T + +type AProvides = Assert, A>> +type ARequires = Assert, never>> +type BProvides = Assert, B>> +type BRequires = Assert, A>> +type CRequires = Assert, A | B>> +void (0 as unknown as AProvides) +void (0 as unknown as ARequires) +void (0 as unknown as BProvides) +void (0 as unknown as BRequires) +void (0 as unknown as CRequires) + +const a = LayerNode.make(aImplementation, []) +const b = LayerNode.make(bImplementation, [a]) +const c = LayerNode.make(cImplementation, [a, b]) +const failingA = LayerNode.make(failingAImplementation, []) +const bWithFailingA = LayerNode.make(bImplementation, [failingA]) +const notFoundA = LayerNode.make(notFoundAImplementation, []) +const diskA = LayerNode.make(diskAImplementation, []) +const networkA = LayerNode.make(networkAImplementation, []) +const notFoundOrDiskA = LayerNode.make(notFoundOrDiskAImplementation, []) + +// @ts-expect-error B requires A +LayerNode.make(bImplementation, []) + +// @ts-expect-error C requires both A and B +LayerNode.make(cImplementation, [a]) + +type ANodeProvides = Assert>> +type BNodeProvides = Assert>> +type CNodeProvides = Assert>> +type FailingANodeError = Assert>> +type DependentNodeError = Assert>> +void (0 as unknown as ANodeProvides) +void (0 as unknown as BNodeProvides) +void (0 as unknown as CNodeProvides) +void (0 as unknown as FailingANodeError) +void (0 as unknown as DependentNodeError) + +const closed = LayerNode.buildLayer(c) +const closedWithError = LayerNode.buildLayer(bWithFailingA) +type ClosedProvides = Assert, C>> +type ClosedRequires = Assert, never>> +type ClosedError = Assert, LayerError>> +void (0 as unknown as ClosedProvides) +void (0 as unknown as ClosedRequires) +void (0 as unknown as ClosedError) + +const replacement = LayerNode.make(Layer.succeed(A, A.of({ value: "a" })), []) +LayerNode.replace(a, replacement) +LayerNode.replace(notFoundOrDiskA, notFoundA) +LayerNode.replace(notFoundOrDiskA, diskA) + +// @ts-expect-error An override for A must still provide A +LayerNode.replace(a, b) + +// @ts-expect-error A replacement cannot introduce NetworkError +LayerNode.replace(notFoundOrDiskA, networkA) + +test("type exploration compiles", () => {}) diff --git a/packages/opencode/test/effect/app-graph.test.ts b/packages/opencode/test/effect/app-graph.test.ts new file mode 100644 index 000000000..5d6e23fc4 --- /dev/null +++ b/packages/opencode/test/effect/app-graph.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, test } from "bun:test" +import { Cause, Context, Effect, Exit, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" + +const { buildLayer: build, group, replace } = LayerNode +const node = LayerNode.make + +class Value extends Context.Service()("test/Value") {} +class Greeting extends Context.Service()("test/Greeting") {} + +const value = LayerNode.make(Layer.succeed(Value, Value.of({ value: "production" })), []) +const greetingImplementation = Layer.effect( + Greeting, + Effect.gen(function* () { + return Greeting.of({ text: `hello ${(yield* Value).value}` }) + }), +) +const greeting = LayerNode.make(greetingImplementation, [value]) + +// @ts-expect-error Greeting requires Value +LayerNode.make(greetingImplementation, []) + +describe("app graph", () => { + test("creates any selected dependency layer", async () => { + const result = Effect.gen(function* () { + return (yield* Greeting).text + }).pipe(Effect.provide(build(greeting))) + + expect(await Effect.runPromise(result)).toBe("hello production") + }) + + test("applies overrides before dependency materialization", async () => { + const replacement = node(Layer.succeed(Value, Value.of({ value: "simulation" })), []) + const graph = build(greeting, { replacements: [replace(value, replacement)] }) + const result = Effect.gen(function* () { + return (yield* Greeting).text + }).pipe(Effect.provide(graph)) + + expect(await Effect.runPromise(result)).toBe("hello simulation") + }) + + test("acquires a shared dependency once", async () => { + class Shared extends Context.Service()("test/Shared") {} + class Left extends Context.Service()("test/Left") {} + class Right extends Context.Service()("test/Right") {} + let acquisitions = 0 + const shared = node( + Layer.effect( + Shared, + Effect.sync(() => { + acquisitions++ + return Shared.of({ value: "shared" }) + }), + ), + [], + ) + const left = node( + Layer.effect( + Left, + Effect.gen(function* () { + return Left.of({ value: `${(yield* Shared).value}-left` }) + }), + ), + [shared], + ) + const right = node( + Layer.effect( + Right, + Effect.gen(function* () { + return Right.of({ value: `${(yield* Shared).value}-right` }) + }), + ), + [shared], + ) + + const result = Effect.gen(function* () { + return [(yield* Left).value, (yield* Right).value] + }).pipe(Effect.provide(build(group([left, right])))) + + expect(await Effect.runPromise(result)).toEqual(["shared-left", "shared-right"]) + expect(acquisitions).toBe(1) + }) + + test("applies a replacement to every transitive consumer", async () => { + class Left extends Context.Service()("test/ReplacementLeft") {} + class Right extends Context.Service()("test/ReplacementRight") {} + const left = node( + Layer.effect( + Left, + Effect.gen(function* () { + return Left.of({ value: (yield* Value).value }) + }), + ), + [value], + ) + const right = node( + Layer.effect( + Right, + Effect.gen(function* () { + return Right.of({ value: (yield* Value).value }) + }), + ), + [value], + ) + const replacement = node(Layer.succeed(Value, Value.of({ value: "simulation" })), []) + const graph = build(group([left, right]), { replacements: [replace(value, replacement)] }) + + const result = Effect.gen(function* () { + return [(yield* Left).value, (yield* Right).value] + }).pipe(Effect.provide(graph)) + + expect(await Effect.runPromise(result)).toEqual(["simulation", "simulation"]) + }) + + test("propagates layer acquisition errors", async () => { + class AcquisitionError { + readonly _tag = "AcquisitionError" + } + const failing = node(Layer.effect(Value, Effect.fail(new AcquisitionError())), []) + const exit = await Effect.runPromiseExit(Effect.provide(Value, build(failing))) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(AcquisitionError) + }) + + test("groups expose every selected service", async () => { + class Count extends Context.Service()("test/Count") {} + const count = node(Layer.succeed(Count, Count.of({ value: 3 })), []) + const result = Effect.gen(function* () { + return { text: (yield* Value).value, count: (yield* Count).value } + }).pipe(Effect.provide(build(group([value, count])))) + + expect(await Effect.runPromise(result)).toEqual({ text: "production", count: 3 }) + }) + + test("builds an empty group", async () => { + expect(await Effect.runPromise(Effect.succeed("ok").pipe(Effect.provide(build(group([])))))).toBe("ok") + }) + + test("builds replacements with their own dependencies", async () => { + class ReplacementConfig extends Context.Service()( + "test/ReplacementConfig", + ) {} + const replacementConfig = node( + Layer.succeed(ReplacementConfig, ReplacementConfig.of({ value: "replacement" })), + [], + ) + const replacement = node( + Layer.effect( + Value, + Effect.gen(function* () { + return Value.of({ value: (yield* ReplacementConfig).value }) + }), + ), + [replacementConfig], + ) + const result = Effect.gen(function* () { + return (yield* Greeting).text + }).pipe(Effect.provide(build(greeting, { replacements: [replace(value, replacement)] }))) + + expect(await Effect.runPromise(result)).toBe("hello replacement") + }) + + test("does not acquire unreachable replacements", async () => { + let acquisitions = 0 + const unreachable = node(Layer.succeed(Value, Value.of({ value: "unreachable" })), []) + const replacement = node( + Layer.effect( + Value, + Effect.sync(() => { + acquisitions++ + return Value.of({ value: "replacement" }) + }), + ), + [], + ) + + await Effect.runPromise(Effect.provide(Greeting, build(greeting, { replacements: [replace(unreachable, replacement)] }))) + + expect(acquisitions).toBe(0) + }) + + test("rejects a direct cycle", () => { + const cyclic = node(Layer.succeed(Value, Value.of({ value: "cyclic" })), []) + ;(cyclic.dependencies as LayerNode.Node[]).push(cyclic) + + expect(() => build(cyclic)).toThrow("Cycle detected in app graph: layer#1 -> layer#1") + }) + + test("rejects an indirect cycle", () => { + const first = node(Layer.succeed(Value, Value.of({ value: "first" })), []) + const second = node(Layer.succeed(Value, Value.of({ value: "second" })), [first]) + const third = node(Layer.succeed(Value, Value.of({ value: "third" })), [second]) + ;(first.dependencies as LayerNode.Node[]).push(third) + + expect(() => build(first)).toThrow("Cycle detected in app graph: layer#1 -> layer#2 -> layer#3 -> layer#1") + }) + + test("rejects a cycle introduced by a replacement", () => { + const replacement = node(Layer.succeed(Value, Value.of({ value: "replacement" })), []) + const consumer = node(greetingImplementation, [value]) + ;(replacement.dependencies as LayerNode.Node[]).push(consumer) + + expect(() => build(consumer, { replacements: [replace(value, replacement)] })).toThrow( + "Cycle detected in app graph: layer#1 -> layer#2 -> layer#1", + ) + }) +})