feat(opencode): add typed application layer graph (#31531)

This commit is contained in:
James Long
2026-06-09 15:31:31 -04:00
committed by GitHub
parent 7a54a2c49c
commit 07e5ea9367
68 changed files with 759 additions and 0 deletions

View File

@@ -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<ChildProcessSpawner, never, FileSystem.FileSyste
)
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
export const node = LayerNode.make(layer, [filesystem, path])
export * as CrossSpawnSpawner from "./cross-spawn-spawner"

View File

@@ -8,6 +8,7 @@ import { Flag } from "../flag/flag"
import { isAbsolute, join } from "path"
import { DatabaseMigration } from "./migration"
import { InstallationChannel } from "../installation/version"
import { LayerNode } from "../effect/layer-node"
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
type DatabaseShape = Effect.Success<typeof makeDatabase>
@@ -58,3 +59,5 @@ export const defaultLayer = Layer.unwrap(
return layerFromPath(path())
}),
).pipe(Layer.provide(Global.defaultLayer))
export const node = LayerNode.make(layerFromPath(path()), [])

View File

@@ -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"

View File

@@ -0,0 +1,95 @@
import { Layer } from "effect"
type RuntimeLayer = Layer.Layer<never, unknown, unknown>
type AnyNode = Node<unknown, unknown>
type NodeList = readonly [] | readonly [AnyNode, ...AnyNode[]]
type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown> ? A : never
type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E> ? E : never
type Missing<Required, Dependencies extends NodeList> = Exclude<Required, Output<Dependencies[number]>>
type CheckDependencies<Implementation extends Layer.Any, Dependencies extends NodeList> = [
Missing<Layer.Services<Implementation>, Dependencies>,
] extends [never]
? unknown
: { readonly "Missing dependencies": Missing<Layer.Services<Implementation>, Dependencies> }
declare const $OutputType: unique symbol
declare const $ErrorType: unique symbol
export type Node<A, E = never> = {
readonly kind: "layer" | "group"
readonly implementation?: Layer.Any
readonly dependencies: readonly AnyNode[]
readonly [$OutputType]?: () => A
readonly [$ErrorType]?: () => E
}
export function make<const Implementation extends Layer.Any, const Items extends NodeList>(
implementation: Implementation,
dependencies: Items & CheckDependencies<Implementation, NoInfer<Items>>,
): Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>> {
return { kind: "layer", implementation: implementation as Layer.Any, dependencies }
}
export function group<const Items extends NodeList>(
dependencies: Items,
): Node<Output<Items[number]>, Error<Items[number]>> {
return { kind: "group", dependencies }
}
export type Replacement<A = unknown> = {
readonly source: Node<A, unknown>
readonly replacement: Node<A, unknown>
}
type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<ReplacementError, SourceError>] extends [never]
? unknown
: { readonly "New replacement errors": Exclude<ReplacementError, SourceError> }
export function replace<A, E, E2>(
source: Node<A, E>,
replacement: Node<NoInfer<A>, E2> & CheckReplacementErrors<E, NoInfer<E2>>,
): Replacement<A> {
return { source, replacement }
}
export function buildLayer<A, E>(node: Node<A, E>, options?: { readonly replacements?: readonly Replacement[] }) {
const replacements = new Map(options?.replacements?.map((item) => [item.source, item.replacement]))
const cache = new Map<AnyNode, RuntimeLayer>()
const visiting = new Set<AnyNode>()
const stack: AnyNode[] = []
const ids = new Map<AnyNode, number>()
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<A, E, never>
}
export * as LayerNode from "./layer-node"

View File

@@ -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))

View File

@@ -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"

View File

@@ -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<Service> = 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)

View File

@@ -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>()("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 {

View File

@@ -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

View File

@@ -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<Interface>) =>
Layer.effect(

View File

@@ -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"

View File

@@ -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)

View File

@@ -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>()("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"

View File

@@ -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])

View File

@@ -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])

View File

@@ -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, [])

View File

@@ -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])

View File

@@ -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])
}