feat(opencode): add typed application layer graph (#31531)
This commit is contained in:
parent
7a54a2c49c
commit
07e5ea9367
68 changed files with 759 additions and 0 deletions
101
packages/opencode/test/effect/app-graph-types.test.ts
Normal file
101
packages/opencode/test/effect/app-graph-types.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { test } from "bun:test"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
|
||||
class A extends Context.Service<A, { readonly value: "a" }>()("test/A") {}
|
||||
class B extends Context.Service<B, { readonly value: "b" }>()("test/B") {}
|
||||
class C extends Context.Service<C, { readonly value: "c" }>()("test/C") {}
|
||||
class LayerError {
|
||||
readonly _tag = "LayerError"
|
||||
}
|
||||
class NotFoundError {
|
||||
readonly _tag = "NotFoundError"
|
||||
}
|
||||
class DiskError {
|
||||
readonly _tag = "DiskError"
|
||||
}
|
||||
class NetworkError {
|
||||
readonly _tag = "NetworkError"
|
||||
}
|
||||
|
||||
const aImplementation = Layer.succeed(A, A.of({ value: "a" }))
|
||||
const bImplementation = Layer.effect(B, Effect.gen(function* () {
|
||||
yield* A
|
||||
return B.of({ value: "b" })
|
||||
}))
|
||||
const cImplementation = Layer.effect(C, Effect.gen(function* () {
|
||||
yield* A
|
||||
yield* B
|
||||
return C.of({ value: "c" })
|
||||
}))
|
||||
const failingAImplementation = Layer.effect(A, Effect.fail(new LayerError()))
|
||||
const notFoundAImplementation = Layer.effect(A, Effect.fail(new NotFoundError()))
|
||||
const diskAImplementation = Layer.effect(A, Effect.fail(new DiskError()))
|
||||
const networkAImplementation = Layer.effect(A, Effect.fail(new NetworkError()))
|
||||
const notFoundOrDiskAImplementation = Layer.effect(
|
||||
A,
|
||||
Effect.fail(new NotFoundError() as NotFoundError | DiskError),
|
||||
)
|
||||
|
||||
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false
|
||||
type Assert<T extends true> = T
|
||||
|
||||
type AProvides = Assert<Equal<Layer.Success<typeof aImplementation>, A>>
|
||||
type ARequires = Assert<Equal<Layer.Services<typeof aImplementation>, never>>
|
||||
type BProvides = Assert<Equal<Layer.Success<typeof bImplementation>, B>>
|
||||
type BRequires = Assert<Equal<Layer.Services<typeof bImplementation>, A>>
|
||||
type CRequires = Assert<Equal<Layer.Services<typeof cImplementation>, A | B>>
|
||||
void (0 as unknown as AProvides)
|
||||
void (0 as unknown as ARequires)
|
||||
void (0 as unknown as BProvides)
|
||||
void (0 as unknown as BRequires)
|
||||
void (0 as unknown as CRequires)
|
||||
|
||||
const a = LayerNode.make(aImplementation, [])
|
||||
const b = LayerNode.make(bImplementation, [a])
|
||||
const c = LayerNode.make(cImplementation, [a, b])
|
||||
const failingA = LayerNode.make(failingAImplementation, [])
|
||||
const bWithFailingA = LayerNode.make(bImplementation, [failingA])
|
||||
const notFoundA = LayerNode.make(notFoundAImplementation, [])
|
||||
const diskA = LayerNode.make(diskAImplementation, [])
|
||||
const networkA = LayerNode.make(networkAImplementation, [])
|
||||
const notFoundOrDiskA = LayerNode.make(notFoundOrDiskAImplementation, [])
|
||||
|
||||
// @ts-expect-error B requires A
|
||||
LayerNode.make(bImplementation, [])
|
||||
|
||||
// @ts-expect-error C requires both A and B
|
||||
LayerNode.make(cImplementation, [a])
|
||||
|
||||
type ANodeProvides = Assert<Equal<typeof a, LayerNode.Node<A, never>>>
|
||||
type BNodeProvides = Assert<Equal<typeof b, LayerNode.Node<B, never>>>
|
||||
type CNodeProvides = Assert<Equal<typeof c, LayerNode.Node<C, never>>>
|
||||
type FailingANodeError = Assert<Equal<typeof failingA, LayerNode.Node<A, LayerError>>>
|
||||
type DependentNodeError = Assert<Equal<typeof bWithFailingA, LayerNode.Node<B, LayerError>>>
|
||||
void (0 as unknown as ANodeProvides)
|
||||
void (0 as unknown as BNodeProvides)
|
||||
void (0 as unknown as CNodeProvides)
|
||||
void (0 as unknown as FailingANodeError)
|
||||
void (0 as unknown as DependentNodeError)
|
||||
|
||||
const closed = LayerNode.buildLayer(c)
|
||||
const closedWithError = LayerNode.buildLayer(bWithFailingA)
|
||||
type ClosedProvides = Assert<Equal<Layer.Success<typeof closed>, C>>
|
||||
type ClosedRequires = Assert<Equal<Layer.Services<typeof closed>, never>>
|
||||
type ClosedError = Assert<Equal<Layer.Error<typeof closedWithError>, LayerError>>
|
||||
void (0 as unknown as ClosedProvides)
|
||||
void (0 as unknown as ClosedRequires)
|
||||
void (0 as unknown as ClosedError)
|
||||
|
||||
const replacement = LayerNode.make(Layer.succeed(A, A.of({ value: "a" })), [])
|
||||
LayerNode.replace(a, replacement)
|
||||
LayerNode.replace(notFoundOrDiskA, notFoundA)
|
||||
LayerNode.replace(notFoundOrDiskA, diskA)
|
||||
|
||||
// @ts-expect-error An override for A must still provide A
|
||||
LayerNode.replace(a, b)
|
||||
|
||||
// @ts-expect-error A replacement cannot introduce NetworkError
|
||||
LayerNode.replace(notFoundOrDiskA, networkA)
|
||||
|
||||
test("type exploration compiles", () => {})
|
||||
208
packages/opencode/test/effect/app-graph.test.ts
Normal file
208
packages/opencode/test/effect/app-graph.test.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { Cause, Context, Effect, Exit, Layer } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
|
||||
const { buildLayer: build, group, replace } = LayerNode
|
||||
const node = LayerNode.make
|
||||
|
||||
class Value extends Context.Service<Value, { readonly value: string }>()("test/Value") {}
|
||||
class Greeting extends Context.Service<Greeting, { readonly text: string }>()("test/Greeting") {}
|
||||
|
||||
const value = LayerNode.make(Layer.succeed(Value, Value.of({ value: "production" })), [])
|
||||
const greetingImplementation = Layer.effect(
|
||||
Greeting,
|
||||
Effect.gen(function* () {
|
||||
return Greeting.of({ text: `hello ${(yield* Value).value}` })
|
||||
}),
|
||||
)
|
||||
const greeting = LayerNode.make(greetingImplementation, [value])
|
||||
|
||||
// @ts-expect-error Greeting requires Value
|
||||
LayerNode.make(greetingImplementation, [])
|
||||
|
||||
describe("app graph", () => {
|
||||
test("creates any selected dependency layer", async () => {
|
||||
const result = Effect.gen(function* () {
|
||||
return (yield* Greeting).text
|
||||
}).pipe(Effect.provide(build(greeting)))
|
||||
|
||||
expect(await Effect.runPromise(result)).toBe("hello production")
|
||||
})
|
||||
|
||||
test("applies overrides before dependency materialization", async () => {
|
||||
const replacement = node(Layer.succeed(Value, Value.of({ value: "simulation" })), [])
|
||||
const graph = build(greeting, { replacements: [replace(value, replacement)] })
|
||||
const result = Effect.gen(function* () {
|
||||
return (yield* Greeting).text
|
||||
}).pipe(Effect.provide(graph))
|
||||
|
||||
expect(await Effect.runPromise(result)).toBe("hello simulation")
|
||||
})
|
||||
|
||||
test("acquires a shared dependency once", async () => {
|
||||
class Shared extends Context.Service<Shared, { readonly value: string }>()("test/Shared") {}
|
||||
class Left extends Context.Service<Left, { readonly value: string }>()("test/Left") {}
|
||||
class Right extends Context.Service<Right, { readonly value: string }>()("test/Right") {}
|
||||
let acquisitions = 0
|
||||
const shared = node(
|
||||
Layer.effect(
|
||||
Shared,
|
||||
Effect.sync(() => {
|
||||
acquisitions++
|
||||
return Shared.of({ value: "shared" })
|
||||
}),
|
||||
),
|
||||
[],
|
||||
)
|
||||
const left = node(
|
||||
Layer.effect(
|
||||
Left,
|
||||
Effect.gen(function* () {
|
||||
return Left.of({ value: `${(yield* Shared).value}-left` })
|
||||
}),
|
||||
),
|
||||
[shared],
|
||||
)
|
||||
const right = node(
|
||||
Layer.effect(
|
||||
Right,
|
||||
Effect.gen(function* () {
|
||||
return Right.of({ value: `${(yield* Shared).value}-right` })
|
||||
}),
|
||||
),
|
||||
[shared],
|
||||
)
|
||||
|
||||
const result = Effect.gen(function* () {
|
||||
return [(yield* Left).value, (yield* Right).value]
|
||||
}).pipe(Effect.provide(build(group([left, right]))))
|
||||
|
||||
expect(await Effect.runPromise(result)).toEqual(["shared-left", "shared-right"])
|
||||
expect(acquisitions).toBe(1)
|
||||
})
|
||||
|
||||
test("applies a replacement to every transitive consumer", async () => {
|
||||
class Left extends Context.Service<Left, { readonly value: string }>()("test/ReplacementLeft") {}
|
||||
class Right extends Context.Service<Right, { readonly value: string }>()("test/ReplacementRight") {}
|
||||
const left = node(
|
||||
Layer.effect(
|
||||
Left,
|
||||
Effect.gen(function* () {
|
||||
return Left.of({ value: (yield* Value).value })
|
||||
}),
|
||||
),
|
||||
[value],
|
||||
)
|
||||
const right = node(
|
||||
Layer.effect(
|
||||
Right,
|
||||
Effect.gen(function* () {
|
||||
return Right.of({ value: (yield* Value).value })
|
||||
}),
|
||||
),
|
||||
[value],
|
||||
)
|
||||
const replacement = node(Layer.succeed(Value, Value.of({ value: "simulation" })), [])
|
||||
const graph = build(group([left, right]), { replacements: [replace(value, replacement)] })
|
||||
|
||||
const result = Effect.gen(function* () {
|
||||
return [(yield* Left).value, (yield* Right).value]
|
||||
}).pipe(Effect.provide(graph))
|
||||
|
||||
expect(await Effect.runPromise(result)).toEqual(["simulation", "simulation"])
|
||||
})
|
||||
|
||||
test("propagates layer acquisition errors", async () => {
|
||||
class AcquisitionError {
|
||||
readonly _tag = "AcquisitionError"
|
||||
}
|
||||
const failing = node(Layer.effect(Value, Effect.fail(new AcquisitionError())), [])
|
||||
const exit = await Effect.runPromiseExit(Effect.provide(Value, build(failing)))
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(AcquisitionError)
|
||||
})
|
||||
|
||||
test("groups expose every selected service", async () => {
|
||||
class Count extends Context.Service<Count, { readonly value: number }>()("test/Count") {}
|
||||
const count = node(Layer.succeed(Count, Count.of({ value: 3 })), [])
|
||||
const result = Effect.gen(function* () {
|
||||
return { text: (yield* Value).value, count: (yield* Count).value }
|
||||
}).pipe(Effect.provide(build(group([value, count]))))
|
||||
|
||||
expect(await Effect.runPromise(result)).toEqual({ text: "production", count: 3 })
|
||||
})
|
||||
|
||||
test("builds an empty group", async () => {
|
||||
expect(await Effect.runPromise(Effect.succeed("ok").pipe(Effect.provide(build(group([])))))).toBe("ok")
|
||||
})
|
||||
|
||||
test("builds replacements with their own dependencies", async () => {
|
||||
class ReplacementConfig extends Context.Service<ReplacementConfig, { readonly value: string }>()(
|
||||
"test/ReplacementConfig",
|
||||
) {}
|
||||
const replacementConfig = node(
|
||||
Layer.succeed(ReplacementConfig, ReplacementConfig.of({ value: "replacement" })),
|
||||
[],
|
||||
)
|
||||
const replacement = node(
|
||||
Layer.effect(
|
||||
Value,
|
||||
Effect.gen(function* () {
|
||||
return Value.of({ value: (yield* ReplacementConfig).value })
|
||||
}),
|
||||
),
|
||||
[replacementConfig],
|
||||
)
|
||||
const result = Effect.gen(function* () {
|
||||
return (yield* Greeting).text
|
||||
}).pipe(Effect.provide(build(greeting, { replacements: [replace(value, replacement)] })))
|
||||
|
||||
expect(await Effect.runPromise(result)).toBe("hello replacement")
|
||||
})
|
||||
|
||||
test("does not acquire unreachable replacements", async () => {
|
||||
let acquisitions = 0
|
||||
const unreachable = node(Layer.succeed(Value, Value.of({ value: "unreachable" })), [])
|
||||
const replacement = node(
|
||||
Layer.effect(
|
||||
Value,
|
||||
Effect.sync(() => {
|
||||
acquisitions++
|
||||
return Value.of({ value: "replacement" })
|
||||
}),
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
await Effect.runPromise(Effect.provide(Greeting, build(greeting, { replacements: [replace(unreachable, replacement)] })))
|
||||
|
||||
expect(acquisitions).toBe(0)
|
||||
})
|
||||
|
||||
test("rejects a direct cycle", () => {
|
||||
const cyclic = node(Layer.succeed(Value, Value.of({ value: "cyclic" })), [])
|
||||
;(cyclic.dependencies as LayerNode.Node<unknown, unknown>[]).push(cyclic)
|
||||
|
||||
expect(() => build(cyclic)).toThrow("Cycle detected in app graph: layer#1 -> layer#1")
|
||||
})
|
||||
|
||||
test("rejects an indirect cycle", () => {
|
||||
const first = node(Layer.succeed(Value, Value.of({ value: "first" })), [])
|
||||
const second = node(Layer.succeed(Value, Value.of({ value: "second" })), [first])
|
||||
const third = node(Layer.succeed(Value, Value.of({ value: "third" })), [second])
|
||||
;(first.dependencies as LayerNode.Node<unknown, unknown>[]).push(third)
|
||||
|
||||
expect(() => build(first)).toThrow("Cycle detected in app graph: layer#1 -> layer#2 -> layer#3 -> layer#1")
|
||||
})
|
||||
|
||||
test("rejects a cycle introduced by a replacement", () => {
|
||||
const replacement = node(Layer.succeed(Value, Value.of({ value: "replacement" })), [])
|
||||
const consumer = node(greetingImplementation, [value])
|
||||
;(replacement.dependencies as LayerNode.Node<unknown, unknown>[]).push(consumer)
|
||||
|
||||
expect(() => build(consumer, { replacements: [replace(value, replacement)] })).toThrow(
|
||||
"Cycle detected in app graph: layer#1 -> layer#2 -> layer#1",
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue