feat(opencode): add typed application layer graph (#31531)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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()), [])
|
||||
|
||||
12
packages/core/src/effect/layer-node-platform.ts
Normal file
12
packages/core/src/effect/layer-node-platform.ts
Normal 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"
|
||||
95
packages/core/src/effect/layer-node.ts
Normal file
95
packages/core/src/effect/layer-node.ts
Normal 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"
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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, [])
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user