Compare commits

...
15 changed files with 697 additions and 202 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@opencode-ai/core": patch
"@opencode-ai/server": patch
---
Centralize application construction in Core, isolate plugin runtime bindings per application, and preserve interruption options through the plugin bridge.
+6
View File
@@ -0,0 +1,6 @@
---
"@opencode-ai/util": patch
"@opencode-ai/core": patch
---
Validate graph replacement outputs and shared dependency conflicts before building services. Resolve dependencies against the final override map and automatically bind Location maps introduced by replacements.
+108
View File
@@ -0,0 +1,108 @@
export * as Application from "./application.js"
export { Options } from "./application/options.js"
import { Effect, Layer } from "effect"
import type { Options } from "./application/options.js"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import type { Node } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Global } from "@opencode-ai/util/global"
import { App } from "./app.js"
import { Bus } from "./bus.js"
import { Config } from "./config.js"
import { Credential } from "./credential.js"
import { Database } from "./database/database.js"
import { AppNodeBuilder } from "./effect/app-node-builder.js"
import { EventLogger } from "./event-logger.js"
import { FileSystemSearch } from "./filesystem/search.js"
import { Watcher } from "./filesystem/watcher.js"
import { InstructionDiscovery } from "./instruction-discovery.js"
import { Job } from "./job.js"
import { LocationActivity } from "./location-activity.js"
import { LocationServiceMap } from "./location-service-map.js"
import { MCP } from "./mcp/index.js"
import { ModelsDev } from "./models-dev.js"
import { PermissionSaved } from "./permission/saved.js"
import { PersistentPty } from "./persistent-pty.js"
import { PluginRuntime } from "./plugin/runtime.js"
import { SdkPlugins } from "./plugin/sdk.js"
import { Project } from "./project.js"
import { PtyTicket } from "./pty/ticket.js"
import { Session } from "./session.js"
import { SessionRestart } from "./session/execution/restart.js"
import { SessionTransfer } from "./session/transfer.js"
import { ShellSelect } from "./shell/select.js"
import { WellKnown } from "./wellknown.js"
import { Workspace } from "./workspace.js"
import { Worktree } from "./worktree.js"
const services = LayerNode.group([
Global.node,
Database.node,
Bus.node,
EventLogger.node,
httpClient,
Job.node,
Project.node,
Worktree.node,
Session.node,
SessionTransfer.node,
SdkPlugins.node,
PermissionSaved.node,
PtyTicket.node,
PersistentPty.node,
Credential.node,
WellKnown.node,
LocationServiceMap.node,
LocationActivity.node,
SessionRestart.node,
Workspace.node,
])
/** Build the standard application without choosing an HTTP or process host. */
export function layer<A = never, E = never>(
options: Options = {},
overrides: LayerNode.Replacements = [],
extra?: Node.GlobalNode<A, E>,
) {
return build(LayerNode.group([services, ...(extra ? [extra] : [])]), [
[Database.node, Database.configured(options.database)],
[Bus.node, Bus.configured({ persist: options.events?.persist })],
[App.node, App.configured(options.app)],
[ModelsDev.node, ModelsDev.configured(options.models)],
[Watcher.node, Watcher.configured({ enabled: options.fs?.filewatcher })],
[FileSystemSearch.node, FileSystemSearch.configured({ fff: options.fs?.fff })],
[Global.node, Global.layerWith(options.config?.directory ? { config: options.config.directory } : {})],
[Config.node, Config.configured(options.config)],
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: options.config?.project })],
[ShellSelect.node, ShellSelect.configured({ gitbash: options.windows?.gitbash })],
[
MCP.node,
MCP.configured({
clientInfo: { name: options.app?.name ?? "opencode", version: options.app?.version ?? "unknown" },
}),
],
...overrides,
])
}
/** Own the global-to-Location runtime connection, including for focused application fixtures. */
export function build<A, E>(root: Node.GlobalNode<A, E>, overrides: LayerNode.Replacements = []) {
return Layer.effectContext(
Effect.gen(function* () {
const scope = yield* Effect.scope
const memoMap = yield* Layer.makeMemoMap
const cell = PluginRuntime.makeCell()
// Location factories must capture this same map, not an enclosing host's map.
return yield* Layer.buildWithMemoMap(
AppNodeBuilder.build(LayerNode.group([root, PluginRuntime.providerNode]), [
[PluginRuntime.node, PluginRuntime.layerWithCell(cell)],
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(cell)],
...overrides,
]),
memoMap,
scope,
)
}),
)
}
+34
View File
@@ -0,0 +1,34 @@
export * as ApplicationOptions from "./options.js"
import { Schema } from "effect"
import { Database } from "../database/database.js"
import { ModelsDev } from "../models-dev.js"
export const Options = Schema.Struct({
app: Schema.optional(
Schema.Struct({
name: Schema.optional(Schema.String),
version: Schema.optional(Schema.String),
channel: Schema.optional(Schema.String),
}),
),
database: Schema.optional(Database.Options),
events: Schema.optional(Schema.Struct({ persist: Schema.optional(Schema.Boolean) })),
models: Schema.optional(ModelsDev.Options),
config: Schema.optional(
Schema.Struct({
directory: Schema.optional(Schema.String),
project: Schema.optional(Schema.Boolean),
file: Schema.optional(Schema.String),
content: Schema.optional(Schema.String),
}),
),
windows: Schema.optional(Schema.Struct({ gitbash: Schema.optional(Schema.String) })),
fs: Schema.optional(
Schema.Struct({
filewatcher: Schema.optional(Schema.Boolean),
fff: Schema.optional(Schema.Boolean),
}),
),
})
export type Options = typeof Options.Type
+1 -6
View File
@@ -5,16 +5,11 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements: LayerNode.Replacements = []) {
// Only build the location service map if it's actually needed
if (!LayerNode.hasUnbound(root, LocationServiceMap.node) || hasReplacement(replacements, LocationServiceMap.node))
return LayerNode.compile(root, replacements)
if (!LayerNode.hasUnbound(root, LocationServiceMap.node, replacements)) return LayerNode.compile(root, replacements)
const locationMap = buildLocationServiceMap(replacements)
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
return LayerNode.compile(root, replacements.concat([[LocationServiceMap.node, locationMapNode]]))
}
function hasReplacement(replacements: LayerNode.Replacements, node: LayerNode.Node<unknown, unknown, any>) {
return replacements.some(([source]) => source.name === node.name)
}
export * as AppNodeBuilder from "./app-node-builder.js"
+1 -1
View File
@@ -76,7 +76,7 @@ export const layerWithCell = (cell: Cell) =>
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
switchAgent: (input) => require(cell, (runtime) => runtime.session.switchAgent(input)),
switchModel: (input) => require(cell, (runtime) => runtime.session.switchModel(input)),
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
interrupt: (sessionID, options) => require(cell, (runtime) => runtime.session.interrupt(sessionID, options)),
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
wait: (sessionID) => require(cell, (runtime) => runtime.session.wait(sessionID)),
context: (sessionID) => require(cell, (runtime) => runtime.session.context(sessionID)),
+146
View File
@@ -0,0 +1,146 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect"
import type { Plugin } from "@opencode-ai/plugin/effect"
import { Global } from "@opencode-ai/util/global"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Application } from "@opencode-ai/core/application"
import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { Tool } from "@opencode-ai/core/tool"
import { tempGlobalLayer } from "./fixture/global"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
const options = {
database: { path: ":memory:" },
config: { project: false, content: JSON.stringify({ plugins: ["-opencode.*"] }) },
models: { fetch: false },
fs: { filewatcher: false, fff: false },
} satisfies Application.Options
describe("Application", () => {
it.live("shares the application's database across isolated Locations", () =>
Effect.gen(function* () {
const directory = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(directory) => Effect.promise(() => directory[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => fs.mkdir(path.join(directory.path, "second")))
const observed: Database.Service["Service"][] = []
const supervisor = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.effect(
PluginSupervisor.Service,
Effect.gen(function* () {
const database = yield* Database.Service
observed.push(database)
return PluginSupervisor.Service.of({ flush: Effect.void })
}),
),
deps: [Database.node],
})
const context = yield* Layer.build(
Application.layer(options, [
[Global.node, tempGlobalLayer],
[PluginSupervisor.node, supervisor],
]),
)
const locations = Context.get(context, LocationServiceMap.Service)
const firstRef = Location.Ref.make({ directory: AbsolutePath.make(directory.path) })
const first = yield* locations.contextEffect(firstRef)
const again = yield* locations.contextEffect(firstRef)
const second = yield* locations.contextEffect(
Location.Ref.make({ directory: AbsolutePath.make(path.join(directory.path, "second")) }),
)
expect(observed).toHaveLength(2)
expect(observed.every((database) => database === Context.get(context, Database.Service))).toBe(true)
expect(Context.get(first, Tool.Service)).toBe(Context.get(again, Tool.Service))
expect(Context.get(first, Tool.Service)).not.toBe(Context.get(second, Tool.Service))
}),
)
it.live("isolates repeated application builds and binds plugins to their owning sessions", () =>
Effect.gen(function* () {
const directory = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(directory) => Effect.promise(() => directory[Symbol.asyncDispose]()),
)
const scope = yield* Effect.scope
const firstScope = yield* Scope.fork(scope)
const secondScope = yield* Scope.fork(scope)
const application = Application.layer(options, [[Global.node, tempGlobalLayer]])
const first = yield* Layer.build(application).pipe(Scope.provide(firstScope))
const second = yield* Layer.build(application).pipe(Scope.provide(secondScope))
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory.path) })
const firstReady = yield* Deferred.make<Plugin.Context>()
const secondReady = yield* Deferred.make<Plugin.Context>()
yield* Context.get(first, SdkPlugins.Service).register({
id: "application-probe",
effect: (context) => Deferred.succeed(firstReady, context),
})
yield* Context.get(second, SdkPlugins.Service).register({
id: "application-probe",
effect: (context) => Deferred.succeed(secondReady, context),
})
yield* Context.get(first, LocationServiceMap.Service).contextEffect(ref).pipe(Scope.provide(firstScope))
yield* Context.get(second, LocationServiceMap.Service).contextEffect(ref).pipe(Scope.provide(secondScope))
const firstPlugin = yield* Deferred.await(firstReady).pipe(Effect.timeout("5 seconds"))
const secondPlugin = yield* Deferred.await(secondReady).pipe(Effect.timeout("5 seconds"))
const firstSession = yield* firstPlugin.session.create({ title: "first application" })
const secondSession = yield* secondPlugin.session.create({ title: "second application" })
expect(Context.get(first, Database.Service)).not.toBe(Context.get(second, Database.Service))
expect((yield* Context.get(first, Session.Service).get(firstSession.id)).title).toBe("first application")
expect(Exit.isFailure(yield* Context.get(second, Session.Service).get(firstSession.id).pipe(Effect.exit))).toBe(
true,
)
yield* Scope.close(firstScope, Exit.void)
expect((yield* secondPlugin.session.get({ sessionID: secondSession.id })).title).toBe("second application")
expect((yield* secondPlugin.session.create({ title: "still running" })).title).toBe("still running")
}),
)
it.live("preserves interruption options through the application-owned plugin bridge", () =>
Effect.gen(function* () {
const seen: { sessionID: Session.ID; options?: { readonly continue?: boolean } }[] = []
const context = yield* Layer.build(
Application.build(PluginRuntime.node, [
[Global.node, tempGlobalLayer],
[Database.node, Database.configured({ path: ":memory:" })],
[
SessionExecution.node,
Layer.succeed(
SessionExecution.Service,
SessionExecution.Service.of({
active: Effect.succeed(new Set()),
resume: () => Effect.void,
wake: () => Effect.void,
awaitIdle: () => Effect.void,
interrupt: (sessionID, options) =>
Effect.sync(() => {
seen.push({ sessionID, options })
return true
}),
}),
),
],
]),
)
const runtime = Context.get(context, PluginRuntime.Service)
const sessionID = Session.ID.create()
expect(yield* runtime.session.interrupt(sessionID, { continue: true })).toBe(true)
expect(seen).toEqual([{ sessionID, options: { continue: true } }])
}),
)
})
@@ -58,6 +58,23 @@ void checkError
LayerNode.compile(a, [[a, Layer.succeed(A, A.of({}))]])
LayerNode.compile(a, [[a, make({ service: A, layer: Layer.succeed(A, A.of({})), deps: [] })]])
LayerNode.compile(a, [[a, make({ service: A, layer: Layer.mergeAll(aLayer, Layer.succeed(B, B.of({}))), deps: [] })]])
const invalidMissingOutputs = () => {
const empty = make({ service: A, layer: Layer.empty, deps: [] })
const bundle = make({ name: "bundle", layer: Layer.mergeAll(aLayer, Layer.succeed(B, B.of({}))), deps: [] })
const partial = make({ name: "bundle", layer: aLayer, deps: [] })
// @ts-expect-error A node replacement cannot remove all source outputs
LayerNode.compile(a, [[a, empty]])
// @ts-expect-error A node replacement must preserve every bundled output
LayerNode.compile(bundle, [[bundle, partial]])
// @ts-expect-error Hoisting enforces the same replacement output coverage
LayerNode.hoist(bundle, tags.values.app, [[bundle, partial]])
// @ts-expect-error Effective-graph inspection validates replacements too
LayerNode.hasUnbound(inputDependent, inputA, [[a, empty]])
}
void invalidMissingOutputs
// @ts-expect-error Replacement must provide A
LayerNode.compile(a, [[a, Layer.succeed(B, B.of({}))]])
@@ -43,6 +43,58 @@ describe("layer node", () => {
void check
})
test("keeps transitive dependencies private at runtime", async () => {
const program = Effect.gen(function* () {
return [(yield* Greeting).value, (yield* Effect.serviceOption(Value))._tag]
}).pipe(Effect.provide(LayerNode.compile(greeting)))
expect(await Effect.runPromise(program)).toEqual(["hello production", "None"])
})
test("builds roots in order and supplies earlier roots to later roots", async () => {
const acquired: string[] = []
const first = make({
service: Value,
layer: Layer.effect(
Value,
Effect.sync(() => {
acquired.push("first")
return Value.of({ value: "first" })
}),
),
deps: [],
})
const second = make({
service: Greeting,
layer: Layer.effect(
Greeting,
Effect.map(Effect.serviceOption(Value), (value) => {
acquired.push("second")
return Greeting.of({ value: value._tag })
}),
),
deps: [],
})
expect(
await Effect.runPromise(
Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(LayerNode.compile(LayerNode.group([first, second]))),
),
),
).toBe("Some")
expect(acquired).toEqual(["first", "second"])
acquired.length = 0
expect(
await Effect.runPromise(
Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(LayerNode.compile(LayerNode.group([second, first]))),
),
),
).toBe("None")
expect(acquired).toEqual(["second", "first"])
})
test("preserves branch-specific implementations across roots", async () => {
const firstValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "first" })), deps: [] })
const secondValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "second" })), deps: [] })
@@ -158,6 +210,128 @@ describe("layer node", () => {
expect(await Effect.runPromise(program)).toBe("hello replacement dependency")
})
test("applies earlier replacements inside later replacement nodes", async () => {
const replacement = make({ service: Greeting, layer: greetingLayer, deps: [value] })
const program = Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(
LayerNode.compile(greeting, [
[value, Layer.succeed(Value, Value.of({ value: "replacement dependency" }))],
[greeting, replacement],
]),
),
)
expect(await Effect.runPromise(program)).toBe("hello replacement dependency")
})
test("rejects replacements matching an actual target in another tag", () => {
const tags = LayerNode.tags({ location: ["global"], global: [] })
const global = tags.make("global")({ service: Value, layer: valueLayer, deps: [] })
const location = tags.make("location")({ service: Value, layer: valueLayer, deps: [] })
const unbound = LayerNode.unbound(Greeting, tags.values.location)
const replacements = [[global, valueLayer]] as const
expect(() => LayerNode.compile(location, replacements)).toThrow("Cannot replace test/LayerNodeValue across tags")
expect(() => LayerNode.hoist(location, tags.values.global, replacements)).toThrow(
"Cannot replace test/LayerNodeValue across tags",
)
expect(() => LayerNode.hasUnbound(location, unbound, replacements)).toThrow(
"Cannot replace test/LayerNodeValue across tags",
)
})
test("preserves same-tag replacements by service name", async () => {
const variant = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "variant" })), deps: [] })
const program = Effect.map(Value, (item) => item.value).pipe(
Effect.provide(LayerNode.compile(variant, [[value, Layer.succeed(Value, Value.of({ value: "replacement" }))]])),
)
expect(await Effect.runPromise(program)).toBe("replacement")
})
test("matches equivalent same-name source definitions", async () => {
const source = make({ service: Greeting, layer: greetingLayer, deps: [value] })
const program = Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(
LayerNode.compile(greeting, [[source, Layer.succeed(Greeting, Greeting.of({ value: "replacement" }))]]),
),
)
expect(await Effect.runPromise(program)).toBe("replacement")
})
test("checks cycles after the final replacement wins", async () => {
const replacementValue = make({
service: Value,
layer: Layer.effect(
Value,
Effect.map(Greeting, (item) => Value.of({ value: item.value })),
),
deps: [greeting],
})
const replacementGreeting = make({ service: Greeting, layer: greetingLayer, deps: [value] })
const cycle = [
[value, replacementValue],
[greeting, replacementGreeting],
] as const
const replacements = [...cycle, [value, value]] as const
expect(() => LayerNode.compile(greeting, cycle)).toThrow("Cycle detected in layer tree")
expect(() => LayerNode.hoist(greeting, tags.values.app, cycle)).toThrow("Cycle detected in layer tree")
const split = LayerNode.hoist(greeting, tags.values.app, replacements)
const read = Effect.map(Greeting, (item) => item.value)
expect(await Effect.runPromise(read.pipe(Effect.provide(LayerNode.compile(greeting, replacements))))).toBe(
"hello production",
)
expect(await Effect.runPromise(read.pipe(Effect.provide(LayerNode.compile(split.hoisted))))).toBe(
"hello production",
)
})
test("applies final overrides to dependencies referencing earlier replacements", async () => {
const profileValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "profile" })), deps: [] })
const profileGreeting = make({ service: Greeting, layer: greetingLayer, deps: [profileValue] })
const overrideValue = make({
service: Value,
layer: Layer.succeed(Value, Value.of({ value: "override" })),
deps: [],
})
const replacements = [
[value, profileValue],
[greeting, profileGreeting],
[value, overrideValue],
] as const
const read = Effect.map(Greeting, (item) => item.value)
const split = LayerNode.hoist(greeting, tags.values.app, replacements)
expect(await Effect.runPromise(read.pipe(Effect.provide(LayerNode.compile(greeting, replacements))))).toBe(
"hello override",
)
expect(await Effect.runPromise(read.pipe(Effect.provide(LayerNode.compile(split.hoisted))))).toBe("hello override")
})
test("inspects unbound nodes in the effective replacement graph", () => {
const unbound = LayerNode.unbound(Value, tags.values.app)
const independent = make({
service: Greeting,
layer: Layer.succeed(Greeting, Greeting.of({ value: "plain" })),
deps: [],
})
const dependent = make({ service: Greeting, layer: greetingLayer, deps: [unbound] })
expect(LayerNode.hasUnbound(independent, unbound)).toBe(false)
expect(LayerNode.hasUnbound(independent, unbound, [[independent, dependent]])).toBe(true)
expect(LayerNode.hasUnbound(dependent, unbound, [[dependent, independent]])).toBe(false)
expect(LayerNode.hasUnbound(dependent, unbound, [[unbound, value]])).toBe(false)
expect(
LayerNode.hasUnbound(independent, unbound, [
[independent, dependent],
[unbound, value],
]),
).toBe(false)
expect(() => LayerNode.hasUnbound(independent, value)).toThrow("Cannot check non-unbound layer node")
})
test("hoists and compiles tagged graphs", async () => {
const tags = LayerNode.tags({ location: ["global"], global: [] })
const global = tags.make("global")
@@ -237,6 +411,100 @@ describe("layer node", () => {
)
})
test("rejects conflicting implementations below another hoisted node", () => {
const competing = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "other" })), deps: [] })
expect(() => LayerNode.hoist(LayerNode.group([greeting, competing]), tags.values.app)).toThrow(
"Tag app has conflicting implementations for test/LayerNodeValue",
)
})
test("rejects hoisted nodes with the same implementation but different dependencies", () => {
const firstValue = LayerNode.make({ service: Value, layer: valueLayer, deps: [] })
const secondValue = LayerNode.make({
service: Value,
layer: Layer.succeed(Value, Value.of({ value: "other" })),
deps: [],
})
const first = make({ service: Greeting, layer: greetingLayer, deps: [firstValue] })
const second = make({ service: Greeting, layer: greetingLayer, deps: [secondValue] })
expect(() => LayerNode.hoist(LayerNode.group([first, second]), tags.values.app)).toThrow(
"Tag app has conflicting implementations for test/LayerNodeGreeting",
)
})
test("deduplicates matching hoisted definitions after applying replacements", async () => {
const duplicate = make({ service: Greeting, layer: greetingLayer, deps: [value] })
const split = LayerNode.hoist(LayerNode.group([greeting, duplicate]), tags.values.app, [
[value, Layer.succeed(Value, Value.of({ value: "replacement" }))],
])
expect(split.hoisted.dependencies).toHaveLength(1)
expect(
await Effect.runPromise(
Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(LayerNode.compile(split.hoisted))),
),
).toBe("hello replacement")
})
test("deduplicates equivalent global closures through transparent dependency groups", () => {
const duplicateValue = make({ service: Value, layer: valueLayer, deps: [] })
const duplicateGreeting = make({
service: Greeting,
layer: greetingLayer,
deps: [LayerNode.group([duplicateValue])],
})
const split = LayerNode.hoist(LayerNode.group([greeting, duplicateGreeting]), tags.values.app)
expect(split.hoisted.dependencies).toEqual([greeting])
})
test("accepts equivalent untagged dependency closures while hoisting", async () => {
const firstValue = LayerNode.make({ service: Value, layer: valueLayer, deps: [] })
const secondValue = LayerNode.make({ service: Value, layer: valueLayer, deps: [] })
const first = make({ service: Greeting, layer: greetingLayer, deps: [firstValue] })
const second = make({ service: Greeting, layer: greetingLayer, deps: [secondValue] })
const split = LayerNode.hoist(LayerNode.group([first, second]), tags.values.app)
expect(split.hoisted.dependencies).toHaveLength(1)
expect(
await Effect.runPromise(
Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(LayerNode.compile(split.hoisted))),
),
).toBe("hello production")
})
test("keeps hoisted services shared outside fresh local builds", async () => {
const acquisitions = { global: 0, local: 0 }
const shared = make({
service: Value,
layer: Layer.effect(
Value,
Effect.sync(() => Value.of({ value: String(++acquisitions.global) })),
),
deps: [],
})
const local = LayerNode.make({
service: Greeting,
layer: Layer.effect(
Greeting,
Effect.map(Value, (item) => Greeting.of({ value: `${item.value}:${++acquisitions.local}` })),
),
deps: [shared],
})
const split = LayerNode.hoist(local, tags.values.app)
const read = Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(Layer.fresh(LayerNode.compile(split.node))),
)
const program = Effect.gen(function* () {
return [yield* read, yield* read]
}).pipe(Effect.provide(LayerNode.compile(split.hoisted)))
expect(await Effect.runPromise(program)).toEqual(["1:1", "1:2"])
expect(acquisitions).toEqual({ global: 1, local: 2 })
})
test("treats dependency groups as transparent while hoisting", () => {
const tags = LayerNode.tags({ location: ["global"], global: [] })
const global = tags.make("global")
@@ -31,6 +31,26 @@ describe("node build", () => {
expect(await Effect.runPromise(program)).toBe("plain")
})
test("binds a location service map introduced by a replacement", async () => {
const original = Node.makeGlobalNode({
service: Result,
layer: Layer.succeed(Result, Result.of({ value: "original" })),
deps: [],
})
const replacement = Node.makeGlobalNode({
service: Result,
layer: Layer.effect(Result, Effect.as(LocationServiceMap.Service, Result.of({ value: "bound" }))),
deps: [LocationServiceMap.node],
})
const value = await Effect.runPromise(
Effect.map(Result, (result) => result.value).pipe(
Effect.provide(AppNodeBuilder.build(original, [[original, replacement]])),
),
)
expect(value).toBe("bound")
})
test("detects cycles through a replaced location service map", async () => {
const a = Node.makeGlobalNode({
service: CycleA,
+4 -5
View File
@@ -5,7 +5,7 @@ import path from "path"
import { describe, expect } from "bun:test"
import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer, Queue, Scope, Stream } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Application } from "@opencode-ai/core/application"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { filesystem } from "@opencode-ai/util/effect/app-node-platform"
@@ -146,7 +146,6 @@ const nodes = LayerNode.group([
Job.node,
Session.node,
SessionExecution.node,
PluginRuntime.providerNode,
LocationServiceMap.node,
filesystem,
FSUtil.node,
@@ -157,10 +156,10 @@ const replacements = [
[Permission.node, permission],
[Global.node, tempGlobalLayer],
] satisfies LayerNode.Replacements
const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements))
const it = testEffect(AppNodeBuilder.build(nodes, [...replacements, [PluginSupervisor.node, shellPluginSupervisor]]))
const productionIt = testEffect(Application.build(nodes, replacements))
const it = testEffect(Application.build(nodes, [...replacements, [PluginSupervisor.node, shellPluginSupervisor]]))
const permissionIt = testEffect(
AppNodeBuilder.build(LayerNode.group([nodes, PermissionSaved.node]), [
Application.build(LayerNode.group([nodes, PermissionSaved.node]), [
[SessionExecution.node, executionNode],
[Global.node, tempGlobalLayer],
[PluginSupervisor.node, shellPluginSupervisor],
+3 -4
View File
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
import path from "path"
import { Money } from "@opencode-ai/schema/money"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Application } from "@opencode-ai/core/application"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -111,15 +111,14 @@ const nodes = LayerNode.group([
Job.node,
Session.node,
SessionExecution.node,
PluginRuntime.providerNode,
LocationServiceMap.node,
])
const replacements = [
[SessionExecution.node, executionNode],
[Global.node, tempGlobalLayer],
] satisfies LayerNode.Replacements
const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements))
const it = testEffect(AppNodeBuilder.build(nodes, [...replacements, [PluginSupervisor.node, subagentPluginSupervisor]]))
const productionIt = testEffect(Application.build(nodes, replacements))
const it = testEffect(Application.build(nodes, [...replacements, [PluginSupervisor.node, subagentPluginSupervisor]]))
const withSubagent = (location: Location.Ref) =>
Effect.gen(function* () {
+2 -35
View File
@@ -1,44 +1,11 @@
import { Database } from "@opencode-ai/core/database/database"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { ApplicationOptions } from "@opencode-ai/core/application/options"
import { Schema } from "effect"
export const ServerOptions = Schema.Struct({
app: Schema.optional(
Schema.Struct({
name: Schema.optional(Schema.String),
version: Schema.optional(Schema.String),
channel: Schema.optional(Schema.String),
}),
),
...ApplicationOptions.Options.fields,
hostname: Schema.optional(Schema.String),
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(65_535))),
password: Schema.optional(Schema.String),
simulation: Schema.optional(Schema.Boolean),
database: Schema.optional(Database.Options),
events: Schema.optional(
Schema.Struct({
persist: Schema.optional(Schema.Boolean),
}),
),
models: Schema.optional(ModelsDev.Options),
config: Schema.optional(
Schema.Struct({
directory: Schema.optional(Schema.String),
project: Schema.optional(Schema.Boolean),
file: Schema.optional(Schema.String),
content: Schema.optional(Schema.String),
}),
),
windows: Schema.optional(
Schema.Struct({
gitbash: Schema.optional(Schema.String),
}),
),
fs: Schema.optional(
Schema.Struct({
filewatcher: Schema.optional(Schema.Boolean),
fff: Schema.optional(Schema.Boolean),
}),
),
})
export type ServerOptions = typeof ServerOptions.Type
+4 -86
View File
@@ -1,35 +1,11 @@
import { Database } from "@opencode-ai/core/database/database"
import { V1Migration } from "@opencode-ai/core/database/v1-migration"
import { App } from "@opencode-ai/core/app"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Bus } from "@opencode-ai/core/bus"
import { EventLogger } from "@opencode-ai/core/event-logger"
import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
import { Credential } from "@opencode-ai/core/credential"
import { Config } from "@opencode-ai/core/config"
import { Application } from "@opencode-ai/core/application"
import type { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { PersistentPty } from "@opencode-ai/core/persistent-pty"
import { Project } from "@opencode-ai/core/project"
import { Session } from "@opencode-ai/core/session"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Job } from "@opencode-ai/core/job"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Global } from "@opencode-ai/util/global"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { LocationActivity } from "@opencode-ai/core/location-activity"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { WellKnown } from "@opencode-ai/core/wellknown"
import { Workspace } from "@opencode-ai/core/workspace"
import { Worktree } from "@opencode-ai/core/worktree"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { HttpRouter } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Context, Effect, Layer, Option } from "effect"
@@ -45,32 +21,6 @@ import { sessionLocationLayer } from "./middleware/session-location"
import { ServerInfo } from "./server-info"
import type { ServerOptions } from "./options"
const applicationServiceNodes = [
Global.node,
Database.node,
Bus.node,
EventLogger.node,
httpClient,
Job.node,
Project.node,
Worktree.node,
Session.node,
SessionTransfer.node,
PluginRuntime.providerNode,
SdkPlugins.node,
PermissionSaved.node,
PtyTicket.node,
PersistentPty.node,
Credential.node,
WellKnown.node,
PtyEnvironment.node,
LocationServiceMap.node,
LocationActivity.node,
SessionRestart.node,
Workspace.node,
] as const
const applicationServices = LayerNode.group(applicationServiceNodes)
export function createRoutes(
options: ServerOptions = {},
serviceURLs: () => ReadonlyArray<string> = () => [],
@@ -97,47 +47,15 @@ function makeRoutes<AuthError, AuthServices>(
// Runtime-profile replacements (e.g. workerd) applied after the standard set, so later entries win.
overrides: LayerNode.Replacements,
) {
const pluginRuntimeCell = PluginRuntime.makeCell()
const standard: LayerNode.Replacements = [
[Database.node, Database.configured(options.database)],
[Bus.node, Bus.configured({ persist: options.events?.persist })],
[App.node, App.configured(options.app)],
[ModelsDev.node, ModelsDev.configured(options.models)],
[Watcher.node, Watcher.configured({ enabled: options.fs?.filewatcher })],
[FileSystemSearch.node, FileSystemSearch.configured({ fff: options.fs?.fff })],
[Global.node, Global.layerWith(options.config?.directory ? { config: options.config.directory } : {})],
[
Config.node,
Config.configured({
project: options.config?.project,
file: options.config?.file,
content: options.config?.content,
}),
],
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: options.config?.project })],
[ShellSelect.node, ShellSelect.configured({ gitbash: options.windows?.gitbash })],
[
MCP.node,
MCP.configured({
clientInfo: {
name: options.app?.name ?? "opencode",
version: options.app?.version ?? "unknown",
},
}),
],
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)],
]
const replacements: LayerNode.Replacements = [...standard, ...overrides]
const serviceLayer = options.simulation
? Layer.unwrap(
Effect.gen(function* () {
const { simulationReplacements } = yield* Effect.promise(() => import("@opencode-ai/simulation/backend"))
const simulation = yield* simulationReplacements({ version: App.make(options.app).version })
return AppNodeBuilder.build(applicationServices, [...replacements, ...simulation])
return Application.layer(options, [...overrides, ...simulation], PtyEnvironment.node)
}),
)
: AppNodeBuilder.build(applicationServices, replacements)
: Application.layer(options, overrides, PtyEnvironment.node)
return serviceLayer.pipe(
Layer.flatMap((context) => {
const services = Layer.succeedContext(context)
+77 -65
View File
@@ -118,9 +118,15 @@ type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<Replacemen
? unknown
: { readonly "New replacement errors": Exclude<ReplacementError, SourceError> }
type CheckReplacementOutputs<SourceOutput, ReplacementOutput> = [Exclude<SourceOutput, ReplacementOutput>] extends [
never,
]
? unknown
: { readonly "Missing replacement outputs": Exclude<SourceOutput, ReplacementOutput> }
type CheckReplacement<Item> = Item extends readonly [Node<infer A, infer E, infer T>, infer Replacement]
? Replacement extends Node<NoInfer<A>, infer E2, T>
? CheckReplacementErrors<E, NoInfer<E2>>
? Replacement extends Node<infer A2, infer E2, T>
? CheckReplacementOutputs<A, NoInfer<A2>> & CheckReplacementErrors<E, NoInfer<E2>>
: Replacement extends Layer.Layer<NoInfer<A>, infer E2, never>
? CheckReplacementErrors<E, NoInfer<E2>>
: { readonly "Invalid replacement": Replacement }
@@ -218,29 +224,42 @@ export function hoist<A, E, T extends Tag, const Items extends Replacements = re
} {
const hoisted = new Map<string, AnyNode>()
const replacementMap = replacementMapFrom(replacements)
const node = walk<AnyNode>(
const definitions = new Map<string, AnyNode>()
// Validate the entire effective closure, including dependencies below hoisted roots.
const effective = 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.implementation !== node.implementation) {
const dependencies = node.dependencies.map(context.visit)
const result = dependencies.every((dependency, index) => dependency === node.dependencies[index])
? node
: { ...node, dependencies }
if (node.kind !== "group" && node.tag === tag) {
const existing = definitions.get(node.name)
if (existing && !sameDefinition(existing, result)) {
throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
}
hoisted.set(node.name, rewriteReplacementDependencies(node, replacementMap))
return group([])
if (existing) return existing
definitions.set(node.name, result)
}
if (node.kind === "unbound") {
return node
}
return { ...node, dependencies: node.dependencies.map(context.visit) }
return result
},
{ resolve: (node) => replacementMap.get(node.name) ?? node },
{ resolve: (node) => resolveReplacement(node, replacementMap) },
)
const node = walk<AnyNode>(effective, (node, context) => {
if (node.kind === "group") {
return { ...node, dependencies: node.dependencies.map(context.visit) }
}
if (node.tag === tag) {
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>,
@@ -264,7 +283,7 @@ export function compile<A, E, const Items extends Replacements = readonly []>(
? implementation
: implementation.pipe(Layer.provide(dependencies as [RuntimeLayer, ...RuntimeLayer[]]))
},
{ cache, resolve: (node) => replacementMap.get(node.name) ?? node },
{ cache, resolve: (node) => resolveReplacement(node, replacementMap) },
)
const layers = flatten(root).map((node) => compileNode(node))
const layer = layers.reduce<RuntimeLayer>((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty)
@@ -272,58 +291,51 @@ export function compile<A, E, const Items extends Replacements = readonly []>(
}
function replacementMapFrom(replacements?: Replacements) {
// Resolve dependencies only after the last override wins, not in intermediate graphs.
return new Map(replacements?.map(([source, replacement]) => [source.name, replacementNode(source, replacement)]))
}
function resolveReplacement(node: AnyNode, replacements: ReturnType<typeof replacementMapFrom>) {
const replacement = replacements.get(node.name)
if (!replacement) return node
if (node.tag !== replacement.tag) {
throw new Error(`Cannot replace ${node.name} across tags`)
}
return replacement
}
function sameDefinition(left: AnyNode, right: AnyNode): boolean {
if (left === right) return true
if (
left.kind !== right.kind ||
left.name !== right.name ||
left.tag !== right.tag ||
left.implementation !== right.implementation
)
return false
const leftDependencies = left.dependencies.flatMap(flatten)
const rightDependencies = right.dependencies.flatMap(flatten)
return (
replacements?.reduce((map, [source, replacement]) => {
const normalized = rewriteReplacementDependencies(replacementNode(source, replacement), map)
const current = new Map([[source.name, normalized]])
for (const [name, node] of map) map.set(name, rewriteReplacementDependencies(node, current))
map.set(source.name, normalized)
return map
}, new Map<string, AnyNode>()) ?? new Map<string, AnyNode>()
leftDependencies.length === rightDependencies.length &&
leftDependencies.every((dependency, index) => sameDefinition(dependency, rightDependencies[index]))
)
}
function rewriteReplacementDependencies(root: AnyNode, replacements: ReadonlyMap<string, AnyNode>) {
if (replacements.size === 0) return root
const cache = new Map<AnyNode, AnyNode>()
const visiting = new Set<AnyNode>()
const stack: AnyNode[] = []
const recur = (node: AnyNode, isRoot = false): AnyNode => {
const target = isRoot ? node : (replacements.get(node.name) ?? node)
const cached = cache.get(target)
if (cached !== undefined || cache.has(target)) return cached!
if (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 dependencies = target.dependencies.map((dependency) => recur(dependency))
const result = dependencies.every((dependency, index) => dependency === target.dependencies[index])
? target
: { ...target, dependencies }
cache.set(target, result)
return result
} finally {
stack.pop()
visiting.delete(target)
}
}
return recur(root, true)
}
export function hasUnbound(root: Node<unknown, unknown, any>, source: AnyNode): boolean {
export function hasUnbound<const Items extends Replacements = readonly []>(
root: Node<unknown, unknown, any>,
source: AnyNode,
replacements?: ValidReplacements<Items>,
): 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)
})
const replacementMap = replacementMapFrom(replacements)
return walk<boolean>(
root,
(node, context) => {
if (node === source) return true
return node.dependencies.some(context.visit)
},
{ resolve: (node) => resolveReplacement(node, replacementMap) },
)
}
function flatten(node: AnyNode): readonly AnyNode[] {