Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
"opencode": "./bin/opencode"
},
"exports": {
"./effect/layer-node": "./src/effect/layer-node/index.ts",
"./effect/node": "./src/effect/node.ts",
"./effect/layer-node": "./src/effect/layer-node.ts",
"./effect/app-node": "./src/effect/app-node.ts",
"./session/runner": "./src/session/runner/index.ts",
"./system-context": "./src/system-context/index.ts",
"./*": "./src/*.ts"
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/agent.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export * as AgentV2 from "./agent"

import { makeLocationNode } from "./effect/node"
import { makeLocationNode } from "./effect/app-node"
import { Array, Context, Effect, Layer, Types } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { State } from "./state"
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/aisdk.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export * as AISDK from "./aisdk"

import { makeLocationNode } from "./effect/node"
import { makeLocationNode } from "./effect/app-node"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Cause, Context, Effect, Layer, Schema, Scope } from "effect"
import { ModelV2 } from "./model"
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/background-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ export * as BackgroundJob from "./background-job"

import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect"
import { Identifier } from "./id/id"
import { makeGlobalNode } from "./effect/node"
import { makeGlobalNode } from "./effect/app-node"

export type Status = "running" | "completed" | "error" | "cancelled"

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/catalog.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export * as Catalog from "./catalog"

import { makeLocationNode } from "./effect/node"
import { makeLocationNode } from "./effect/app-node"
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect"
import { Catalog } from "@opencode-ai/schema/catalog"
import { ModelV2 } from "./model"
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/command.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export * as CommandV2 from "./command"

import { makeLocationNode } from "./effect/node"
import { makeLocationNode } from "./effect/app-node"
import { Context, Effect, Layer, Types } from "effect"
import { Command } from "@opencode-ai/schema/command"
import { State } from "./state"
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export * as Config from "./config"

import { makeLocationNode } from "./effect/node"
import { makeLocationNode } from "./effect/app-node"
import path from "path"
import { type ParseError, parse } from "jsonc-parser"
import { Context, Effect, Layer, Option, Schema } from "effect"
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/credential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Context, Effect, Layer, Schema } from "effect"
import { Credential } from "@opencode-ai/schema/credential"
import { Integration } from "@opencode-ai/schema/integration"
import { Database } from "./database/database"
import { makeGlobalNode } from "./effect/node"
import { makeGlobalNode } from "./effect/app-node"
import { CredentialTable } from "./credential/sql"

export const ID = Credential.ID
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/cross-spawn-spawner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ import {
import * as NodeChildProcess from "node:child_process"
import { PassThrough } from "node:stream"
import launch from "cross-spawn"
import { makeGlobalNode } from "./effect/node"
import { filesystem, path } from "./effect/layer-node-platform"
import { makeGlobalNode } from "./effect/app-node"
import { filesystem, path } from "./effect/app-node-platform"

const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err)))

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/database/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Flag } from "../flag/flag"
import { isAbsolute, join } from "path"
import { DatabaseMigration } from "./migration"
import { InstallationChannel } from "../installation/version"
import { makeGlobalNode } from "../effect/node"
import { makeGlobalNode } from "../effect/app-node"

const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
type DatabaseShape = Effect.Success<typeof makeDatabase>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
import { Layer } from "effect"
import { buildLocationServiceMap } from "../location-services"
import { LocationServiceMap } from "../location-service-map"
import { LayerNode, LayerNodeTree } from "./layer-node"
import { makeGlobalNode } from "./node"
import { LayerNode } from "./layer-node"
import { makeGlobalNode } from "./app-node"

export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements?: readonly LayerNode.Replacement[]) {
const replacementMap = new Map(replacements?.map((item) => [item.source, item.replacement]))

if (!LayerNodeTree.hasUnbound(root, LocationServiceMap.node)) {
if (!LayerNode.hasUnbound(root, LocationServiceMap.node)) {
// If the location service map is not needed, we shouldn't pull it
// in. Compile the graph normally
return LayerNodeTree.compile(root, replacementMap)
return LayerNode.compile(root, replacementMap)
}

const locationMap = buildLocationServiceMap(replacementMap)
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })

const app = LayerNodeTree.bind(root, LocationServiceMap.node, locationMapNode)
const app = LayerNode.bind(root, LocationServiceMap.node, locationMapNode)

return LayerNodeTree.compile(app, replacementMap)
return LayerNode.compile(app, replacementMap)
}

export * as NodeBuild from "./node-build"
export * as NodeBuild from "./app-node-builder"
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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 { makeGlobalNode } from "./node"
import { makeGlobalNode } from "./app-node"

export const filesystem = makeGlobalNode({ service: FileSystem.FileSystem, layer: NodeFileSystem.layer, deps: [] })
export const path = makeGlobalNode({ service: Path.Path, layer: NodePath.layer, deps: [] })
Expand All @@ -15,4 +15,4 @@ export const requestExecutor = makeGlobalNode({
})
export const llmClient = makeGlobalNode({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] })

export * as LayerNodePlatform from "./layer-node-platform"
export * as LayerNodePlatform from "./app-node-platform"
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ export type LocationNode<A, E = never> = LayerNode.Node<A, E, (typeof tags.value
export const makeGlobalNode = tags.make("global")
export const makeLocationNode = tags.make("location")

export * as Node from "./node"
export * as Node from "./app-node"
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Brand, Context, Layer } from "effect"

type AnyNode = Node<unknown, unknown, any>
type RuntimeLayer = Layer.Layer<never, unknown, unknown>
type NodeList<Item extends AnyNode = AnyNode> = readonly [] | readonly [Item, ...Item[]]
export type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown, any> ? A : never
export type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E, any> ? E : never
Expand Down Expand Up @@ -34,6 +35,39 @@ type NodeIdentity =
| { readonly name: string; readonly service?: never }
type DistributiveOmit<A, K extends PropertyKey> = A extends unknown ? Omit<A, K> : never

export type TagConfig = Readonly<Record<string, readonly string[]>>
type TagNames<Config extends TagConfig> = keyof Config & string
type NodeInTags<Names extends string> = Node<unknown, unknown, Tag<Names> | undefined>
type CheckTags<Items extends NodeList, Names extends string> = [Exclude<Items[number], NodeInTags<Names>>] extends [
never,
]
? unknown
: { readonly "Invalid tag dependencies": Exclude<Items[number], NodeInTags<Names>> }

export interface Tags<Config extends TagConfig> {
readonly values: { readonly [Name in TagNames<Config>]: Tag<Name> }
readonly make: <Name extends TagNames<Config>>(
name: Name,
) => <const Implementation extends Layer.Any, const Items extends NodeList>(
input: DistributiveOmit<MakeInput<Implementation, Items, Tag<Name>>, "tag"> &
CheckTags<Items, Name | Extract<Config[Name][number], string>>,
) => Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, Tag<Name>>
}

export function tags<const Config extends { readonly [Name in keyof Config]: readonly (keyof Config & string)[] }>(
config: Config,
): Tags<Config> {
const names = Object.keys(config) as TagNames<Config>[]
const values = Object.fromEntries(names.map((name) => [name, makeTag(name)])) as Tags<Config>["values"]
return {
values,
make: ((name: TagNames<Config>) => (input: DistributiveOmit<MakeInput<Layer.Any, NodeList, Tag>, "tag">) =>
make({ ...input, tag: values[name] })) as Tags<Config>["make"],
}
}

// Nodes ---------------------------------------------------------------------

type MakeInput<
Implementation extends Layer.Any,
Items extends NodeList,
Expand Down Expand Up @@ -77,37 +111,6 @@ export function group<const Items extends readonly AnyNode[]>(
return { kind: "group", name: "group", dependencies }
}

export type TagConfig = Readonly<Record<string, readonly string[]>>
type TagNames<Config extends TagConfig> = keyof Config & string
type NodeInTags<Names extends string> = Node<unknown, unknown, Tag<Names> | undefined>
type CheckTags<Items extends NodeList, Names extends string> = [Exclude<Items[number], NodeInTags<Names>>] extends [
never,
]
? unknown
: { readonly "Invalid tag dependencies": Exclude<Items[number], NodeInTags<Names>> }

export interface Tags<Config extends TagConfig> {
readonly values: { readonly [Name in TagNames<Config>]: Tag<Name> }
readonly make: <Name extends TagNames<Config>>(
name: Name,
) => <const Implementation extends Layer.Any, const Items extends NodeList>(
input: DistributiveOmit<MakeInput<Implementation, Items, Tag<Name>>, "tag"> &
CheckTags<Items, Name | Extract<Config[Name][number], string>>,
) => Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, Tag<Name>>
}

export function tags<const Config extends { readonly [Name in keyof Config]: readonly (keyof Config & string)[] }>(
config: Config,
): Tags<Config> {
const names = Object.keys(config) as TagNames<Config>[]
const values = Object.fromEntries(names.map((name) => [name, makeTag(name)])) as Tags<Config>["values"]
return {
values,
make: ((name: TagNames<Config>) => (input: DistributiveOmit<MakeInput<Layer.Any, NodeList, Tag>, "tag">) =>
make({ ...input, tag: values[name] })) as Tags<Config>["make"],
}
}

export type Replacement = {
readonly source: Layer.Any
readonly replacement: Layer.Any
Expand All @@ -124,4 +127,147 @@ export function replace<A, E, R, E2>(
return { source, replacement }
}

// Tree -----------------------------------------------------------------------

type Visit<Result> = (node: AnyNode, context: VisitContext<Result>) => Result

type VisitContext<Result> = {
readonly cache: Map<AnyNode, Result>
readonly visit: (node: AnyNode) => Result
}

function walk<Result>(
root: AnyNode,
visit: Visit<Result>,
options: {
readonly cache?: Map<AnyNode, Result>
readonly resolve?: (node: AnyNode) => AnyNode
readonly detectCycles?: boolean
} = {},
) {
const cache = options.cache ?? new Map<AnyNode, Result>()
const visiting = new Set<AnyNode>()
const stack: AnyNode[] = []

const recur = (node: AnyNode): Result => {
const target = options.resolve?.(node) ?? node
const cached = cache.get(target)
if (cached !== undefined || cache.has(target)) return cached!

if (options.detectCycles !== false && visiting.has(target)) {
const start = stack.indexOf(target)
throw new Error(
`Cycle detected in layer tree: ${[...stack.slice(start), target].map((item) => item.name).join(" -> ")}`,
)
}

visiting.add(target)
stack.push(target)
try {
const result = visit(target, { cache, visit: recur })
if (!cache.has(target)) cache.set(target, result)
return result
} finally {
stack.pop()
visiting.delete(target)
}
}

return recur(root)
}

export function hoist<A, E, T extends Tag>(
root: Node<A, E, any>,
tag: T,
): {
readonly node: Node<A, E>
readonly hoisted: Node<unknown, E>
} {
const hoisted = new Map<string, AnyNode>()

const node = walk<AnyNode>(root, (node, context) => {
if (node.kind === "group") {
return { ...node, dependencies: node.dependencies.map(context.visit) }
}
if (node.tag === tag) {
const existing = hoisted.get(node.name)
if (existing && existing !== node) {
throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
}
hoisted.set(node.name, node)
return group([])
}
if (node.kind === "unbound") {
return node
}
return { ...node, dependencies: node.dependencies.map(context.visit) }
})

return {
node: node as Node<A, E>,
hoisted: group(Array.from(hoisted.values())) as Node<unknown, E>,
}
}

export function compile<A, E>(
root: Node<A, E, any>,
replacements?: ReadonlyMap<Layer.Any, Layer.Any>,
): Layer.Layer<A, E> {
const cache = new Map<AnyNode, RuntimeLayer>()
const compileNode = (node: AnyNode) =>
walk<RuntimeLayer>(
node,
(node, context) => {
if (node.kind === "unbound") throw new Error(`Unbound layer node: ${node.name}`)
const dependencies = node.dependencies.flatMap(flatten).map(context.visit)
const implementation = (replacements?.get(node.implementation!) ?? node.implementation!) as RuntimeLayer
return dependencies.length === 0
? implementation
: implementation.pipe(Layer.provide(dependencies as [RuntimeLayer, ...RuntimeLayer[]]))
},
{ cache },
)
const layers = flatten(root).map((node) => compileNode(node))
const layer = layers.reduce<RuntimeLayer>((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty)
return layer as Layer.Layer<A, E>
}

export function hasUnbound(root: Node<unknown, unknown, any>, source: AnyNode): boolean {
if (source.kind !== "unbound") throw new Error(`Cannot check non-unbound layer node: ${source.name}`)
return walk<boolean>(root, (node, context) => {
if (node === source) return true
return node.dependencies.some(context.visit)
})
}

export function bind<A, E, T extends Tag | undefined>(
root: Node<A, E, T>,
source: AnyNode,
replacement: AnyNode,
): Node<A, E, T> {
if (source.kind !== "unbound") throw new Error(`Cannot bind non-unbound layer node: ${source.name}`)
if (source.name !== replacement.name) {
throw new Error(`Cannot bind ${source.name} to ${replacement.name}`)
}
if (source.tag !== replacement.tag) {
throw new Error(`Cannot bind ${source.name} across tags`)
}
return walk<AnyNode>(
root,
(target, context) => {
if (target.kind === "unbound") return target
const dependencies: AnyNode[] = []
const clone = { ...target, dependencies }
context.cache.set(target, clone)
dependencies.push(...target.dependencies.map(context.visit))
return clone
},
{ detectCycles: false, resolve: (node) => (node === source ? replacement : node) },
) as Node<A, E, T>
}

function flatten(node: AnyNode): readonly AnyNode[] {
return node.kind === "group" ? node.dependencies.flatMap(flatten) : [node]
}

export * as LayerNode from "./layer-node"
2 changes: 0 additions & 2 deletions packages/core/src/effect/layer-node/index.ts

This file was deleted.

Loading
Loading