mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 20:16:17 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1237af0e1 | ||
|
|
ecbe2fa7ea |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user