chore: merge dev into v2 (#34317)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Affan Ali <93028901+affanali2k3@users.noreply.github.com> Co-authored-by: affanali2k3 <affanalikhanxx@gmail.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: James Long <longster@gmail.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Jay V <air@live.ca> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: Ben Guthrie <benjee.012@gmail.com> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Filip <34747899+neriousy@users.noreply.github.com>
This commit is contained in:
parent
11bf8d8a42
commit
41283933ff
208 changed files with 9815 additions and 6651 deletions
|
|
@ -16,6 +16,8 @@
|
|||
"opencode": "./bin/opencode"
|
||||
},
|
||||
"exports": {
|
||||
"./effect/layer-node": "./src/effect/layer-node.ts",
|
||||
"./effect/app-node": "./src/effect/app-node.ts",
|
||||
"./session/runner": "./src/session/runner/index.ts",
|
||||
"./system-context": "./src/system-context/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * as AgentV2 from "./agent"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Array, Context, Effect, Layer, Types } from "effect"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { State } from "./state"
|
||||
|
|
@ -106,3 +107,5 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * as AISDK from "./aisdk"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { Cause, Context, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
|
|
@ -231,4 +232,6 @@ export const locationLayer = Layer.effect(
|
|||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
|
||||
|
||||
export const defaultLayer = locationLayer
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ export * as BackgroundJob from "./background-job"
|
|||
|
||||
import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect"
|
||||
import { Identifier } from "./id/id"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
|
||||
export type Status = "running" | "completed" | "error" | "cancelled"
|
||||
|
||||
|
|
@ -362,3 +363,5 @@ export const make = Effect.gen(function* () {
|
|||
export const layer = Layer.effect(Service, make)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * as Catalog from "./catalog"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect"
|
||||
import { Catalog } from "@opencode-ai/schema/catalog"
|
||||
import { ModelV2 } from "./model"
|
||||
|
|
@ -296,3 +297,5 @@ export const locationLayer = layer.pipe(
|
|||
Layer.provideMerge(Integration.locationLayer),
|
||||
Layer.provideMerge(Policy.locationLayer),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Policy.node, Integration.node] })
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * as CommandV2 from "./command"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Context, Effect, Layer, Types } from "effect"
|
||||
import { Command } from "@opencode-ai/schema/command"
|
||||
import { State } from "./state"
|
||||
|
|
@ -59,3 +60,5 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * as Config from "./config"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import path from "path"
|
||||
import { type ParseError, parse } from "jsonc-parser"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
|
|
@ -218,3 +219,9 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provideMerge(Policy.locationLayer))
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [FSUtil.node, Global.node, Location.node, Policy.node],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Context, Effect, Layer, Schema } from "effect"
|
|||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Database } from "./database/database"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { CredentialTable } from "./credential/sql"
|
||||
|
||||
export const ID = Credential.ID
|
||||
|
|
@ -135,3 +136,5 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ import {
|
|||
import * as NodeChildProcess from "node:child_process"
|
||||
import { PassThrough } from "node:stream"
|
||||
import launch from "cross-spawn"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { filesystem, path } from "./effect/layer-node-platform"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { filesystem, path } from "./effect/app-node-platform"
|
||||
|
||||
const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err)))
|
||||
|
||||
|
|
@ -503,6 +503,6 @@ export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSyste
|
|||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
|
||||
export const node = LayerNode.make({ service: ChildProcessSpawner, layer, deps: [filesystem, path] })
|
||||
export const node = makeGlobalNode({ service: ChildProcessSpawner, layer, deps: [filesystem, path] })
|
||||
|
||||
export * as CrossSpawnSpawner from "./cross-spawn-spawner"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { Flag } from "../flag/flag"
|
|||
import { isAbsolute, join } from "path"
|
||||
import { DatabaseMigration } from "./migration"
|
||||
import { InstallationChannel } from "../installation/version"
|
||||
import { LayerNode } from "../effect/layer-node"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
|
||||
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
|
||||
type DatabaseShape = Effect.Success<typeof makeDatabase>
|
||||
|
|
@ -60,4 +60,4 @@ export const defaultLayer = Layer.unwrap(
|
|||
}),
|
||||
).pipe(Layer.provide(Global.defaultLayer))
|
||||
|
||||
export const node = LayerNode.make({ service: Service, layer: layerFromPath(path()), deps: [] })
|
||||
export const node = makeGlobalNode({ service: Service, layer: layerFromPath(path()), deps: [] })
|
||||
|
|
|
|||
24
packages/core/src/effect/app-node-builder.ts
Normal file
24
packages/core/src/effect/app-node-builder.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { Layer } from "effect"
|
||||
import { buildLocationServiceMap } from "../location-services"
|
||||
import { LocationServiceMap } from "../location-service-map"
|
||||
import { LayerNode } from "./layer-node"
|
||||
import { makeGlobalNode } from "./app-node"
|
||||
|
||||
export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements?: readonly LayerNode.Replacement[]) {
|
||||
const replacementMap = new Map(replacements?.map((item) => [item.source, item.replacement]))
|
||||
|
||||
if (!LayerNode.hasUnbound(root, LocationServiceMap.node)) {
|
||||
// If the location service map is not needed, we shouldn't pull it
|
||||
// in. Compile the graph normally
|
||||
return LayerNode.compile(root, replacementMap)
|
||||
}
|
||||
|
||||
const locationMap = buildLocationServiceMap(replacementMap)
|
||||
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
|
||||
|
||||
const app = LayerNode.bind(root, LocationServiceMap.node, locationMapNode)
|
||||
|
||||
return LayerNode.compile(app, replacementMap)
|
||||
}
|
||||
|
||||
export * as AppNodeBuilder from "./app-node-builder"
|
||||
|
|
@ -3,16 +3,16 @@ import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
|
|||
import { FileSystem, Path } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { LayerNode } from "./layer-node"
|
||||
import { makeGlobalNode } from "./app-node"
|
||||
|
||||
export const filesystem = LayerNode.make({ service: FileSystem.FileSystem, layer: NodeFileSystem.layer, deps: [] })
|
||||
export const path = LayerNode.make({ service: Path.Path, layer: NodePath.layer, deps: [] })
|
||||
export const httpClient = LayerNode.make({ service: HttpClient.HttpClient, layer: FetchHttpClient.layer, deps: [] })
|
||||
export const requestExecutor = LayerNode.make({
|
||||
export const filesystem = makeGlobalNode({ service: FileSystem.FileSystem, layer: NodeFileSystem.layer, deps: [] })
|
||||
export const path = makeGlobalNode({ service: Path.Path, layer: NodePath.layer, deps: [] })
|
||||
export const httpClient = makeGlobalNode({ service: HttpClient.HttpClient, layer: FetchHttpClient.layer, deps: [] })
|
||||
export const requestExecutor = makeGlobalNode({
|
||||
service: RequestExecutor.Service,
|
||||
layer: RequestExecutor.layer,
|
||||
deps: [httpClient],
|
||||
})
|
||||
export const llmClient = LayerNode.make({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] })
|
||||
export const llmClient = makeGlobalNode({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] })
|
||||
|
||||
export * as LayerNodePlatform from "./layer-node-platform"
|
||||
export * as LayerNodePlatform from "./app-node-platform"
|
||||
14
packages/core/src/effect/app-node.ts
Normal file
14
packages/core/src/effect/app-node.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { LayerNode } from "./layer-node"
|
||||
|
||||
export const tags = LayerNode.tags({
|
||||
location: ["global"],
|
||||
global: [],
|
||||
})
|
||||
|
||||
export type GlobalNode<A, E = never> = LayerNode.Node<A, E, (typeof tags.values)["global"]>
|
||||
export type LocationNode<A, E = never> = LayerNode.Node<A, E, (typeof tags.values)["location"]>
|
||||
|
||||
export const makeGlobalNode = tags.make("global")
|
||||
export const makeLocationNode = tags.make("location")
|
||||
|
||||
export * as Node from "./app-node"
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import { Brand, Context, Layer } from "effect"
|
||||
|
||||
type RuntimeLayer = Layer.Layer<never, unknown, unknown>
|
||||
type AnyNode = Node<unknown, unknown, any>
|
||||
type RuntimeLayer = Layer.Layer<never, unknown, unknown>
|
||||
type NodeList<Item extends AnyNode = AnyNode> = readonly [] | readonly [Item, ...Item[]]
|
||||
type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown, any> ? A : never
|
||||
type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E, any> ? E : never
|
||||
export type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown, any> ? A : never
|
||||
export type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E, any> ? E : never
|
||||
type NodeTag<Item> = [Item] extends [never] ? undefined : Item extends Node<unknown, unknown, infer T> ? T : never
|
||||
type Missing<Required, Dependencies extends NodeList> = Exclude<Required, Output<Dependencies[number]>>
|
||||
type CheckDependencies<Implementation extends Layer.Any, Dependencies extends NodeList> = [
|
||||
Missing<Layer.Services<Implementation>, Dependencies>,
|
||||
|
|
@ -14,17 +15,17 @@ type CheckDependencies<Implementation extends Layer.Any, Dependencies extends No
|
|||
declare const $OutputType: unique symbol
|
||||
declare const $ErrorType: unique symbol
|
||||
|
||||
export type Tier<Name extends string = string> = Name & Brand.Brand<"LayerNode.Tier">
|
||||
export type Tag<Name extends string = string> = Name & Brand.Brand<"LayerNode.Tag">
|
||||
|
||||
const makeTier = Brand.nominal<Tier>()
|
||||
const makeTag = Brand.nominal<Tag>()
|
||||
|
||||
export type Node<A, E = never, T extends Tier | undefined = undefined> = {
|
||||
readonly kind: "layer" | "group"
|
||||
export interface Node<A, E = never, T extends Tag | undefined = undefined> {
|
||||
readonly kind: "layer" | "unbound" | "group"
|
||||
readonly name: string
|
||||
readonly service?: Context.Service.Any
|
||||
readonly implementation?: Layer.Any
|
||||
readonly dependencies: readonly AnyNode[]
|
||||
readonly tier?: T
|
||||
readonly tag?: T
|
||||
readonly [$OutputType]?: () => A
|
||||
readonly [$ErrorType]?: () => E
|
||||
}
|
||||
|
|
@ -34,22 +35,55 @@ type NodeIdentity =
|
|||
| { readonly name: string; readonly service?: never }
|
||||
type DistributiveOmit<A, K extends PropertyKey> = A extends unknown ? Omit<A, K> : never
|
||||
|
||||
type NodeInput<
|
||||
export type TagConfig = Readonly<Record<string, readonly string[]>>
|
||||
type TagNames<Config extends TagConfig> = keyof Config & string
|
||||
type NodeInTags<Names extends string> = Node<unknown, unknown, Tag<Names> | undefined>
|
||||
type CheckTags<Items extends NodeList, Names extends string> = [Exclude<Items[number], NodeInTags<Names>>] extends [
|
||||
never,
|
||||
]
|
||||
? unknown
|
||||
: { readonly "Invalid tag dependencies": Exclude<Items[number], NodeInTags<Names>> }
|
||||
|
||||
export interface Tags<Config extends TagConfig> {
|
||||
readonly values: { readonly [Name in TagNames<Config>]: Tag<Name> }
|
||||
readonly make: <Name extends TagNames<Config>>(
|
||||
name: Name,
|
||||
) => <const Implementation extends Layer.Any, const Items extends NodeList>(
|
||||
input: DistributiveOmit<MakeInput<Implementation, Items, Tag<Name>>, "tag"> &
|
||||
CheckTags<Items, Name | Extract<Config[Name][number], string>>,
|
||||
) => Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, Tag<Name>>
|
||||
}
|
||||
|
||||
export function tags<const Config extends { readonly [Name in keyof Config]: readonly (keyof Config & string)[] }>(
|
||||
config: Config,
|
||||
): Tags<Config> {
|
||||
const names = Object.keys(config) as TagNames<Config>[]
|
||||
const values = Object.fromEntries(names.map((name) => [name, makeTag(name)])) as Tags<Config>["values"]
|
||||
return {
|
||||
values,
|
||||
make: ((name: TagNames<Config>) => (input: DistributiveOmit<MakeInput<Layer.Any, NodeList, Tag>, "tag">) =>
|
||||
make({ ...input, tag: values[name] })) as Tags<Config>["make"],
|
||||
}
|
||||
}
|
||||
|
||||
// Nodes ---------------------------------------------------------------------
|
||||
|
||||
type MakeInput<
|
||||
Implementation extends Layer.Any,
|
||||
Items extends NodeList,
|
||||
T extends Tier | undefined = undefined,
|
||||
T extends Tag | undefined = undefined,
|
||||
> = NodeIdentity & {
|
||||
readonly layer: Implementation
|
||||
readonly deps: Items & CheckDependencies<Implementation, NoInfer<Items>>
|
||||
readonly tier?: T
|
||||
readonly tag?: T
|
||||
}
|
||||
|
||||
export function make<
|
||||
const Implementation extends Layer.Any,
|
||||
const Items extends NodeList,
|
||||
const T extends Tier | undefined = undefined,
|
||||
const T extends Tag | undefined = undefined,
|
||||
>(
|
||||
input: NodeInput<Implementation, Items, T>,
|
||||
input: MakeInput<Implementation, Items, T>,
|
||||
): Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, T> {
|
||||
return {
|
||||
kind: "layer",
|
||||
|
|
@ -57,53 +91,26 @@ export function make<
|
|||
service: input.service,
|
||||
implementation: input.layer,
|
||||
dependencies: input.deps,
|
||||
tier: input.tier,
|
||||
tag: input.tag,
|
||||
}
|
||||
}
|
||||
|
||||
export function group<const Items extends NodeList>(
|
||||
export function unbound<R, Shape, const T extends Tag>(service: Context.Key<R, Shape>, tag: T): Node<R, never, T> {
|
||||
return {
|
||||
kind: "unbound",
|
||||
name: service.key,
|
||||
service,
|
||||
dependencies: [],
|
||||
tag,
|
||||
}
|
||||
}
|
||||
|
||||
export function group<const Items extends readonly AnyNode[]>(
|
||||
dependencies: Items,
|
||||
): Node<Output<Items[number]>, Error<Items[number]>> {
|
||||
): Node<Output<Items[number]>, Error<Items[number]>, NodeTag<Items[number]>> {
|
||||
return { kind: "group", name: "group", dependencies }
|
||||
}
|
||||
|
||||
type AllowedTierNames<Names extends readonly string[], Name extends Names[number]> = Names extends readonly [
|
||||
infer Head extends string,
|
||||
...infer Tail extends readonly string[],
|
||||
]
|
||||
? Head extends Name
|
||||
? Head | Tail[number]
|
||||
: AllowedTierNames<Tail, Name>
|
||||
: never
|
||||
|
||||
type NodeInTiers<Names extends string> = Node<unknown, unknown, Tier<Names>>
|
||||
|
||||
export interface Tiers<Names extends readonly [string, ...string[]]> {
|
||||
readonly names: Names
|
||||
readonly values: { readonly [K in Names[number]]: Tier<K> }
|
||||
readonly make: <Name extends Names[number]>(
|
||||
name: Name,
|
||||
) => <
|
||||
const Implementation extends Layer.Any,
|
||||
const Items extends NodeList<NodeInTiers<AllowedTierNames<Names, Name>>>,
|
||||
>(
|
||||
input: DistributiveOmit<NodeInput<Implementation, Items, Tier<Name>>, "tier">,
|
||||
) => Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, Tier<Name>>
|
||||
}
|
||||
|
||||
export function tiers<const Names extends readonly [string, ...string[]]>(names: Names): Tiers<Names> {
|
||||
const values = Object.fromEntries(names.map((name) => [name, makeTier(name)])) as Tiers<Names>["values"]
|
||||
return {
|
||||
names,
|
||||
values,
|
||||
make: ((name: Names[number]) => (input: DistributiveOmit<NodeInput<Layer.Any, NodeList, Tier>, "tier">) =>
|
||||
make({ ...input, tier: values[name] })) as Tiers<Names>["make"],
|
||||
}
|
||||
}
|
||||
|
||||
const defaultTiers = tiers(["untiered"])
|
||||
const untiered = defaultTiers.values.untiered
|
||||
|
||||
export type Replacement = {
|
||||
readonly source: Layer.Any
|
||||
readonly replacement: Layer.Any
|
||||
|
|
@ -120,129 +127,147 @@ export function replace<A, E, R, E2>(
|
|||
return { source, replacement }
|
||||
}
|
||||
|
||||
export function buildLayer<
|
||||
A,
|
||||
E,
|
||||
const Names extends readonly [string, ...string[]] = readonly ["untiered"],
|
||||
const Built extends Layer.Any = Layer.Layer<never, never, never>,
|
||||
>(
|
||||
node: Node<A, E, any>,
|
||||
options?: {
|
||||
readonly tiers?: Tiers<Names>
|
||||
readonly buildTier?: (tier: Names[number], layers: readonly Layer.Any[]) => Built
|
||||
readonly replacements?: readonly Replacement[]
|
||||
},
|
||||
): Layer.Layer<A | Layer.Success<Built>, E | Layer.Error<Built>, never> {
|
||||
const tiers = options?.tiers ?? (defaultTiers as unknown as Tiers<Names>)
|
||||
const replacementMap = new Map(options?.replacements?.map((item) => [item.source, item.replacement]))
|
||||
const plans = plan(node, tiers, replacementMap)
|
||||
const layers: RuntimeLayer[] = tiers.names.map((name) => {
|
||||
const tier = tiers.values[name as Names[number]]
|
||||
const layers = plans.get(tier) ?? []
|
||||
return (options?.buildTier?.(name, layers) ?? combine(layers)) as RuntimeLayer
|
||||
})
|
||||
if (layers.length === 0) return Layer.empty as never
|
||||
return layers.slice(1).reduce((result, layer) => result.pipe(Layer.provideMerge(layer)), layers[0]) as never
|
||||
// Tree -----------------------------------------------------------------------
|
||||
|
||||
type Visit<Result> = (node: AnyNode, context: VisitContext<Result>) => Result
|
||||
|
||||
type VisitContext<Result> = {
|
||||
readonly cache: Map<AnyNode, Result>
|
||||
readonly visit: (node: AnyNode) => Result
|
||||
}
|
||||
|
||||
export function combine(layers: readonly Layer.Any[]): RuntimeLayer {
|
||||
return layers.reduce<RuntimeLayer>(
|
||||
(result, layer) => (layer as RuntimeLayer).pipe(Layer.provideMerge(result)),
|
||||
Layer.empty as RuntimeLayer,
|
||||
)
|
||||
}
|
||||
|
||||
function plan(
|
||||
function walk<Result>(
|
||||
root: AnyNode,
|
||||
tiers: Tiers<readonly [string, ...string[]]>,
|
||||
replacements: ReadonlyMap<Layer.Any, Layer.Any>,
|
||||
visit: Visit<Result>,
|
||||
options: {
|
||||
readonly cache?: Map<AnyNode, Result>
|
||||
readonly resolve?: (node: AnyNode) => AnyNode
|
||||
readonly detectCycles?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const indexes = new Map(tiers.names.map((name, index) => [tiers.values[name], index]))
|
||||
const plans = new Map<Tier, Layer.Any[]>()
|
||||
const activeImplementations = new Map<Tier, Map<string, AnyNode>>()
|
||||
const serviceTiers = new Map<string, Tier>()
|
||||
const cache = options.cache ?? new Map<AnyNode, Result>()
|
||||
const visiting = new Set<AnyNode>()
|
||||
const stack: AnyNode[] = []
|
||||
const boundaryVisited = new Map<AnyNode, Set<Tier>>()
|
||||
const boundaryServices = new Map<Tier, Map<string, AnyNode>>()
|
||||
|
||||
const validateBoundary = (node: AnyNode, origin: Tier) => {
|
||||
const checked = boundaryVisited.get(node) ?? new Set<Tier>()
|
||||
boundaryVisited.set(node, checked)
|
||||
if (checked.has(origin)) return false
|
||||
checked.add(origin)
|
||||
const services = boundaryServices.get(origin) ?? new Map<string, AnyNode>()
|
||||
boundaryServices.set(origin, services)
|
||||
const key = node.name
|
||||
const existing = services.get(key)
|
||||
if (existing && existing !== node) {
|
||||
throw new Error(`Tier ${origin} has conflicting implementations for ${key}`)
|
||||
}
|
||||
services.set(key, node)
|
||||
return true
|
||||
}
|
||||
const recur = (node: AnyNode): Result => {
|
||||
const target = options.resolve?.(node) ?? node
|
||||
const cached = cache.get(target)
|
||||
if (cached !== undefined || cache.has(target)) return cached!
|
||||
|
||||
const visit = (node: AnyNode, currentTier?: Tier, origins: readonly Tier[] = []) => {
|
||||
if (node.kind === "group") {
|
||||
node.dependencies.forEach((dependency) => visit(dependency, currentTier, origins))
|
||||
return
|
||||
}
|
||||
|
||||
const tier = node.tier ?? untiered
|
||||
if (!indexes.has(tier)) throw new Error(`Node ${node.name} is not in the tier configuration`)
|
||||
const key = node.name
|
||||
const serviceTier = serviceTiers.get(key)
|
||||
if (serviceTier && serviceTier !== tier) {
|
||||
throw new Error(`Service ${key} belongs to both tier ${serviceTier} and tier ${tier}`)
|
||||
}
|
||||
serviceTiers.set(key, tier)
|
||||
const nextOrigins = [...origins]
|
||||
if (currentTier) {
|
||||
const current = indexes.get(currentTier)!
|
||||
const required = indexes.get(tier)!
|
||||
if (required < current) {
|
||||
throw new Error(`Tier ${currentTier} cannot depend on lower tier ${tier}`)
|
||||
}
|
||||
if (required > current) nextOrigins.push(currentTier)
|
||||
}
|
||||
const unseenOrigins = nextOrigins.filter((origin) => validateBoundary(node, origin))
|
||||
|
||||
// A node may need to be emitted more than once because the final output is a
|
||||
// flat list of layers applied with Layer.provideMerge. If another node for
|
||||
// the same service was emitted afterward, this node is no longer the active
|
||||
// implementation for subsequent consumers. Re-emitting restores the intended
|
||||
// implementation ordering while Effect memoization avoids reacquiring the layer.
|
||||
const implementations = activeImplementations.get(tier) ?? new Map<string, AnyNode>()
|
||||
activeImplementations.set(tier, implementations)
|
||||
if (implementations.get(key) === node && unseenOrigins.length === 0) return
|
||||
|
||||
if (visiting.has(node)) {
|
||||
const start = stack.indexOf(node)
|
||||
if (options.detectCycles !== false && visiting.has(target)) {
|
||||
const start = stack.indexOf(target)
|
||||
throw new Error(
|
||||
`Cycle detected in layer graph: ${[...stack.slice(start), node].map((item) => item.name).join(" -> ")}`,
|
||||
`Cycle detected in layer tree: ${[...stack.slice(start), target].map((item) => item.name).join(" -> ")}`,
|
||||
)
|
||||
}
|
||||
|
||||
visiting.add(node)
|
||||
stack.push(node)
|
||||
visiting.add(target)
|
||||
stack.push(target)
|
||||
try {
|
||||
node.dependencies.forEach((dependency) => visit(dependency, tier, unseenOrigins))
|
||||
const layers = plans.get(tier) ?? []
|
||||
plans.set(tier, layers)
|
||||
layers.push(replacements.get(node.implementation!) ?? node.implementation!)
|
||||
implementations.set(key, node)
|
||||
const result = visit(target, { cache, visit: recur })
|
||||
if (!cache.has(target)) cache.set(target, result)
|
||||
return result
|
||||
} finally {
|
||||
stack.pop()
|
||||
visiting.delete(node)
|
||||
visiting.delete(target)
|
||||
}
|
||||
}
|
||||
|
||||
visit(root)
|
||||
return plans
|
||||
return recur(root)
|
||||
}
|
||||
|
||||
function requireTier(node: AnyNode, indexes: ReadonlyMap<Tier, number>) {
|
||||
if (!node.tier || !indexes.has(node.tier)) throw new Error(`Node ${node.name} is not in the tier configuration`)
|
||||
export function hoist<A, E, T extends Tag>(
|
||||
root: Node<A, E, any>,
|
||||
tag: T,
|
||||
): {
|
||||
readonly node: Node<A, E>
|
||||
readonly hoisted: Node<unknown, E>
|
||||
} {
|
||||
const hoisted = new Map<string, AnyNode>()
|
||||
|
||||
const node = walk<AnyNode>(root, (node, context) => {
|
||||
if (node.kind === "group") {
|
||||
return { ...node, dependencies: node.dependencies.map(context.visit) }
|
||||
}
|
||||
if (node.tag === tag) {
|
||||
const existing = hoisted.get(node.name)
|
||||
if (existing && existing !== node) {
|
||||
throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
|
||||
}
|
||||
hoisted.set(node.name, node)
|
||||
return group([])
|
||||
}
|
||||
if (node.kind === "unbound") {
|
||||
return node
|
||||
}
|
||||
return { ...node, dependencies: node.dependencies.map(context.visit) }
|
||||
})
|
||||
|
||||
return {
|
||||
node: node as Node<A, E>,
|
||||
hoisted: group(Array.from(hoisted.values())) as Node<unknown, E>,
|
||||
}
|
||||
}
|
||||
|
||||
export function compile<A, E>(
|
||||
root: Node<A, E, any>,
|
||||
replacements?: ReadonlyMap<Layer.Any, Layer.Any>,
|
||||
): Layer.Layer<A, E> {
|
||||
const cache = new Map<AnyNode, RuntimeLayer>()
|
||||
const compileNode = (node: AnyNode) =>
|
||||
walk<RuntimeLayer>(
|
||||
node,
|
||||
(node, context) => {
|
||||
if (node.kind === "unbound") throw new Error(`Unbound layer node: ${node.name}`)
|
||||
const dependencies = node.dependencies.flatMap(flatten).map(context.visit)
|
||||
const implementation = (replacements?.get(node.implementation!) ?? node.implementation!) as RuntimeLayer
|
||||
return dependencies.length === 0
|
||||
? implementation
|
||||
: implementation.pipe(Layer.provide(dependencies as [RuntimeLayer, ...RuntimeLayer[]]))
|
||||
},
|
||||
{ cache },
|
||||
)
|
||||
const layers = flatten(root).map((node) => compileNode(node))
|
||||
const layer = layers.reduce<RuntimeLayer>((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty)
|
||||
return layer as Layer.Layer<A, E>
|
||||
}
|
||||
|
||||
export function hasUnbound(root: Node<unknown, unknown, any>, source: AnyNode): boolean {
|
||||
if (source.kind !== "unbound") throw new Error(`Cannot check non-unbound layer node: ${source.name}`)
|
||||
return walk<boolean>(root, (node, context) => {
|
||||
if (node === source) return true
|
||||
return node.dependencies.some(context.visit)
|
||||
})
|
||||
}
|
||||
|
||||
export function bind<A, E, T extends Tag | undefined>(
|
||||
root: Node<A, E, T>,
|
||||
source: AnyNode,
|
||||
replacement: AnyNode,
|
||||
): Node<A, E, T> {
|
||||
if (source.kind !== "unbound") throw new Error(`Cannot bind non-unbound layer node: ${source.name}`)
|
||||
if (source.name !== replacement.name) {
|
||||
throw new Error(`Cannot bind ${source.name} to ${replacement.name}`)
|
||||
}
|
||||
if (source.tag !== replacement.tag) {
|
||||
throw new Error(`Cannot bind ${source.name} across tags`)
|
||||
}
|
||||
return walk<AnyNode>(
|
||||
root,
|
||||
(target, context) => {
|
||||
if (target.kind === "unbound") return target
|
||||
const dependencies: AnyNode[] = []
|
||||
const clone = { ...target, dependencies }
|
||||
context.cache.set(target, clone)
|
||||
dependencies.push(...target.dependencies.map(context.visit))
|
||||
return clone
|
||||
},
|
||||
{ detectCycles: false, resolve: (node) => (node === source ? replacement : node) },
|
||||
) as Node<A, E, T>
|
||||
}
|
||||
|
||||
function flatten(node: AnyNode): readonly AnyNode[] {
|
||||
return node.kind === "group" ? node.dependencies.flatMap(flatten) : [node]
|
||||
}
|
||||
|
||||
export * as LayerNode from "./layer-node"
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
import { LayerNode } from "./layer-node"
|
||||
|
||||
export const tiers = LayerNode.tiers(["location", "global"])
|
||||
|
||||
export type GlobalNode<A, E = never> = LayerNode.Node<A, E, (typeof tiers.values)["global"]>
|
||||
export type LocationNode<A, E = never> = LayerNode.Node<A, E, (typeof tiers.values)["location"]>
|
||||
|
||||
export const makeGlobalNode = tiers.make("global")
|
||||
export const makeLocationNode = tiers.make("location")
|
||||
|
||||
export * as ScopedNode from "./scoped-node"
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
export * as EventV2 from "./event"
|
||||
|
||||
import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
|
||||
import { and, asc, eq, gt } from "drizzle-orm"
|
||||
import { and, asc, eq, gt, inArray } from "drizzle-orm"
|
||||
import { Database } from "./database/database"
|
||||
import { EventSequenceTable, EventTable } from "./event/sql"
|
||||
import { Location } from "./location"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { Durable } from "@opencode-ai/schema/durable-event-manifest"
|
||||
|
||||
|
|
@ -47,6 +47,71 @@ export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDur
|
|||
},
|
||||
) {}
|
||||
|
||||
const decodeSerializedEvent = (event: SerializedEvent): Payload => {
|
||||
const definition = Durable.get(event.type)
|
||||
if (!definition?.durable) {
|
||||
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
|
||||
}
|
||||
return {
|
||||
id: event.id,
|
||||
type: definition.type,
|
||||
durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
|
||||
data: Schema.decodeUnknownSync(definition.data)(event.data),
|
||||
}
|
||||
}
|
||||
|
||||
export const readAggregate = Effect.fn("EventV2.readAggregate")(function* <A>(
|
||||
db: Database.Interface["db"],
|
||||
input: {
|
||||
readonly aggregateID: string
|
||||
readonly after?: number
|
||||
readonly limit: number
|
||||
readonly manifest: {
|
||||
readonly definitions: ReadonlyMap<string, Definition>
|
||||
readonly schema: Schema.Decoder<A, never>
|
||||
}
|
||||
},
|
||||
) {
|
||||
const after = input.after ?? -1
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(
|
||||
and(
|
||||
eq(EventTable.aggregate_id, input.aggregateID),
|
||||
gt(EventTable.seq, after),
|
||||
inArray(EventTable.type, Array.from(input.manifest.definitions.keys())),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.limit(input.limit + 1)
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const page = rows.slice(0, input.limit)
|
||||
const decode = Schema.decodeUnknownSync(input.manifest.schema)
|
||||
const events = page.map((event) =>
|
||||
decode({
|
||||
id: event.id,
|
||||
type: input.manifest.definitions.get(event.type)?.type ?? event.type,
|
||||
durable: {
|
||||
aggregateID: event.aggregate_id,
|
||||
seq: event.seq,
|
||||
version: input.manifest.definitions.get(event.type)?.durable?.version,
|
||||
},
|
||||
data: event.data,
|
||||
}),
|
||||
)
|
||||
return {
|
||||
events,
|
||||
hasMore: rows.length > input.limit,
|
||||
}
|
||||
})
|
||||
|
||||
export class SubscriberOverflowError extends Schema.TaggedErrorClass<SubscriberOverflowError>()(
|
||||
"EventV2.SubscriberOverflow",
|
||||
{ capacity: Schema.Int },
|
||||
) {}
|
||||
|
||||
export const define = Event.define
|
||||
export const versionedType = Event.versionedType
|
||||
|
||||
|
|
@ -84,6 +149,20 @@ export interface Interface {
|
|||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
|
||||
|
||||
export const allBounded = (events: Interface, capacity: number) =>
|
||||
Effect.gen(function* () {
|
||||
const queue = yield* Queue.dropping<Payload, SubscriberOverflowError>(capacity)
|
||||
const unsubscribe = yield* events.listen((event) =>
|
||||
Queue.offer(queue, event).pipe(
|
||||
Effect.flatMap((accepted) =>
|
||||
accepted ? Effect.void : Queue.fail(queue, new SubscriberOverflowError({ capacity })).pipe(Effect.asVoid),
|
||||
),
|
||||
),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe.pipe(Effect.andThen(Queue.shutdown(queue)), Effect.asVoid))
|
||||
return Stream.fromQueue(queue)
|
||||
})
|
||||
|
||||
export interface LayerOptions {
|
||||
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
|
||||
}
|
||||
|
|
@ -459,19 +538,6 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
|
||||
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all)
|
||||
|
||||
const decodeSerializedEvent = (event: SerializedEvent) => {
|
||||
const definition = Durable.get(event.type)
|
||||
if (!definition?.durable) {
|
||||
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
|
||||
}
|
||||
return {
|
||||
id: event.id,
|
||||
type: definition.type,
|
||||
durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
|
||||
data: Schema.decodeUnknownSync(definition.data)(event.data),
|
||||
}
|
||||
}
|
||||
|
||||
const readAfter = (aggregateID: string, after: number) =>
|
||||
(options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
|
||||
Effect.andThen(
|
||||
|
|
@ -569,6 +635,6 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
)
|
||||
|
||||
export const layer = layerWith()
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] })
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] })
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * as FileMutation from "./file-mutation"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { dirname } from "path"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
|
|
@ -192,6 +193,8 @@ function sameBytes(left: Uint8Array, right: Uint8Array) {
|
|||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
|
||||
|
||||
/**
|
||||
* Deferred until the corresponding V2 integrations exist.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * as FileSystem from "./filesystem"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { FSUtil } from "./fs-util"
|
||||
|
|
@ -113,3 +114,9 @@ const baseLayer = Layer.effect(
|
|||
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.locationLayer), Layer.provide(FSUtil.defaultLayer))
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: baseLayer,
|
||||
deps: [FSUtil.node, Location.node, FileSystemSearch.node],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * as FileSystemSearch from "./search"
|
||||
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { Fff } from "#fff"
|
||||
|
|
@ -229,6 +230,8 @@ export const fffLayer = Layer.effect(
|
|||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = Layer.unwrap(
|
||||
Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)),
|
||||
)
|
||||
const layer = Layer.unwrap(Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)))
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Location.node, Ripgrep.node] })
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export * as Watcher from "./watcher"
|
|||
// @ts-ignore
|
||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
import type ParcelWatcher from "@parcel/watcher"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { Cause, Context, Effect, Layer } from "effect"
|
||||
import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
|
||||
import path from "path"
|
||||
|
|
@ -133,3 +134,9 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer), Layer.provide(Git.defaultLayer))
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [FSUtil.node, Location.node, Config.node, Git.node, EventV2.node],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import { Context, Effect, FileSystem, Layer, Schema } from "effect"
|
|||
import type { PlatformError } from "effect/PlatformError"
|
||||
import { Glob } from "./util/glob"
|
||||
import { serviceUse } from "./effect/service-use"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { filesystem } from "./effect/layer-node-platform"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { filesystem } from "./effect/app-node-platform"
|
||||
|
||||
export namespace FSUtil {
|
||||
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
|
||||
|
|
@ -201,7 +201,7 @@ export namespace FSUtil {
|
|||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [filesystem] })
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [filesystem] })
|
||||
|
||||
// Pure helpers that don't need Effect (path manipulation, sync operations)
|
||||
export function mimeType(p: string): string {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { ChildProcess } from "effect/unstable/process"
|
|||
import { AbsolutePath, RelativePath } from "./schema"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { AppProcess } from "./process"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { File } from "./file"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
|
||||
|
|
@ -944,7 +944,7 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer))
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, AppProcess.node] })
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, AppProcess.node] })
|
||||
|
||||
interface Result {
|
||||
readonly exitCode: number
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import os from "os"
|
|||
import { Context, Effect, Layer } from "effect"
|
||||
import { Flock } from "./util/flock"
|
||||
import { Flag } from "./flag/flag"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
|
||||
const app = "opencode"
|
||||
const data = path.join(xdgData!, app)
|
||||
|
|
@ -77,7 +77,7 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [] })
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [] })
|
||||
|
||||
export const layerWith = (input: Partial<Interface>) =>
|
||||
Layer.effect(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * as Image from "./image"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Config } from "./config"
|
||||
import { FileSystem } from "./filesystem"
|
||||
|
|
@ -76,3 +77,5 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Config.node] })
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { Location } from "./location"
|
|||
import { AbsolutePath } from "./schema"
|
||||
import { SystemContext } from "./system-context/index"
|
||||
import { SystemContextRegistry } from "./system-context/registry"
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
|
||||
class File extends Schema.Class<File>("InstructionContext.File")({
|
||||
path: AbsolutePath,
|
||||
|
|
@ -87,6 +88,12 @@ export const layer = Layer.effectDiscard(
|
|||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "instruction-context",
|
||||
layer,
|
||||
deps: [FSUtil.node, Global.node, Location.node, SystemContextRegistry.node],
|
||||
})
|
||||
|
||||
function render(files: ReadonlyArray<File>) {
|
||||
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * as Integration from "./integration"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import {
|
||||
Cause,
|
||||
Clock,
|
||||
|
|
@ -515,3 +516,5 @@ export const locationLayer = Layer.effect(
|
|||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [Credential.node, EventV2.node] })
|
||||
|
|
|
|||
|
|
@ -1,151 +0,0 @@
|
|||
import { Effect, Layer, LayerMap } from "effect"
|
||||
import { Location } from "./location"
|
||||
import { Policy } from "./policy"
|
||||
import { Config } from "./config"
|
||||
import { PluginV2 } from "./plugin"
|
||||
import { Catalog } from "./catalog"
|
||||
import { Integration } from "./integration"
|
||||
import { CommandV2 } from "./command"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { PluginInternal } from "./plugin/internal"
|
||||
import { Project } from "./project"
|
||||
import { ProjectCopy } from "./project/copy"
|
||||
import { ProjectDirectories } from "./project/directories"
|
||||
import { EventV2 } from "./event"
|
||||
import { Credential } from "./credential"
|
||||
import { Npm } from "./npm"
|
||||
import { ModelsDev } from "./models-dev"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Git } from "./git"
|
||||
import { Global } from "./global"
|
||||
import { Database } from "./database/database"
|
||||
import { PermissionV2 } from "./permission"
|
||||
import { PermissionSaved } from "./permission/saved"
|
||||
import { FileSystem } from "./filesystem"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
import { Watcher } from "./filesystem/watcher"
|
||||
import { LocationMutation } from "./location-mutation"
|
||||
import { FileMutation } from "./file-mutation"
|
||||
import { Reference } from "./reference"
|
||||
import { ReferenceGuidance } from "./reference/guidance"
|
||||
import { RepositoryCache } from "./repository-cache"
|
||||
import { Pty } from "./pty"
|
||||
import { SkillV2 } from "./skill"
|
||||
import { SkillGuidance } from "./skill/guidance"
|
||||
import { BuiltInTools } from "./tool/builtins"
|
||||
import { Image } from "./image"
|
||||
import { ToolRegistry } from "./tool/registry"
|
||||
import { ApplicationTools } from "./tool/application-tools"
|
||||
import { ToolOutputStore } from "./tool-output-store"
|
||||
import { AppProcess } from "./process"
|
||||
import { SessionStore } from "./session/store"
|
||||
import { SessionTodo } from "./session/todo"
|
||||
import { QuestionV2 } from "./question"
|
||||
import { LLMClient } from "@opencode-ai/llm"
|
||||
import { RequestExecutor } from "@opencode-ai/llm/route"
|
||||
import * as SessionRunnerLLM from "./session/runner/llm"
|
||||
import { SessionRunnerModel } from "./session/runner/model"
|
||||
import { SystemContextBuiltIns } from "./system-context/builtins"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { Snapshot } from "./snapshot"
|
||||
|
||||
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
|
||||
lookup: (ref: Location.Ref) => {
|
||||
const boot = Layer.effectDiscard(
|
||||
Effect.logInfo("booting location services", { directory: ref.directory, workspaceID: ref.workspaceID }),
|
||||
)
|
||||
const location = Location.layer(ref)
|
||||
const systemContext = SystemContextBuiltIns.locationLayer
|
||||
const base = Layer.mergeAll(
|
||||
location,
|
||||
Policy.locationLayer,
|
||||
Config.locationLayer,
|
||||
Reference.locationLayer,
|
||||
PluginV2.locationLayer,
|
||||
Catalog.locationLayer,
|
||||
Integration.locationLayer,
|
||||
CommandV2.locationLayer,
|
||||
AgentV2.locationLayer,
|
||||
PluginInternal.locationLayer,
|
||||
ProjectCopy.locationLayer,
|
||||
FileSystem.locationLayer,
|
||||
Watcher.locationLayer,
|
||||
Pty.locationLayer,
|
||||
SkillV2.locationLayer,
|
||||
systemContext,
|
||||
LocationMutation.locationLayer.pipe(Layer.orDie),
|
||||
).pipe(Layer.provideMerge(location))
|
||||
const resources = ToolOutputStore.layer.pipe(Layer.provide(base))
|
||||
const permissionsAndTools = ToolRegistry.layer.pipe(
|
||||
Layer.provideMerge(PermissionV2.locationLayer),
|
||||
Layer.provide(resources),
|
||||
Layer.provide(base),
|
||||
)
|
||||
const services = Layer.mergeAll(base, resources, permissionsAndTools)
|
||||
const image = Image.layer.pipe(Layer.provide(services))
|
||||
const mutation = FileMutation.locationLayer.pipe(Layer.provide(services))
|
||||
const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services))
|
||||
const referenceGuidance = ReferenceGuidance.locationLayer.pipe(Layer.provide(services))
|
||||
const todos = SessionTodo.layer.pipe(Layer.provide(services))
|
||||
const questions = QuestionV2.locationLayer.pipe(Layer.provide(services))
|
||||
const builtInTools = BuiltInTools.locationLayer.pipe(
|
||||
Layer.provide(services),
|
||||
Layer.provide(mutation),
|
||||
Layer.provide(resources),
|
||||
Layer.provide(todos),
|
||||
Layer.provide(questions),
|
||||
Layer.provide(image),
|
||||
)
|
||||
const model = SessionRunnerModel.locationLayer.pipe(Layer.provide(services))
|
||||
const snapshot = Snapshot.locationLayer.pipe(Layer.provide(services))
|
||||
const runner = SessionRunnerLLM.defaultLayer.pipe(
|
||||
Layer.provide(services),
|
||||
Layer.provide(model),
|
||||
Layer.provide(skillGuidance),
|
||||
Layer.provide(referenceGuidance),
|
||||
Layer.provide(snapshot),
|
||||
)
|
||||
|
||||
// Kick off a background project copy refresh to update locations now that we
|
||||
// have a location
|
||||
const projectCopyRefresh = Layer.effectDiscard(ProjectCopy.refreshAfterBoot).pipe(Layer.provide(services))
|
||||
|
||||
return Layer.mergeAll(
|
||||
boot,
|
||||
services,
|
||||
image,
|
||||
mutation,
|
||||
resources,
|
||||
todos,
|
||||
questions,
|
||||
model,
|
||||
snapshot,
|
||||
runner,
|
||||
builtInTools,
|
||||
referenceGuidance,
|
||||
projectCopyRefresh,
|
||||
).pipe(Layer.fresh)
|
||||
},
|
||||
idleTimeToLive: "60 minutes",
|
||||
dependencies: [
|
||||
Project.defaultLayer,
|
||||
EventV2.defaultLayer,
|
||||
Credential.defaultLayer,
|
||||
Npm.defaultLayer,
|
||||
ModelsDev.defaultLayer,
|
||||
FSUtil.defaultLayer,
|
||||
Git.defaultLayer,
|
||||
AppProcess.defaultLayer,
|
||||
Global.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
Database.defaultLayer,
|
||||
ProjectDirectories.defaultLayer,
|
||||
SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)),
|
||||
PermissionSaved.defaultLayer,
|
||||
RepositoryCache.defaultLayer,
|
||||
LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer)),
|
||||
FetchHttpClient.layer,
|
||||
ToolOutputStore.defaultCleanupLayer,
|
||||
ApplicationTools.layer,
|
||||
],
|
||||
}) {}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
export * as LocationMutation from "./location-mutation"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { FSUtil } from "./fs-util"
|
||||
|
|
@ -153,3 +154,9 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer.pipe(Layer.orDie),
|
||||
deps: [FSUtil.node, Location.node],
|
||||
})
|
||||
|
|
|
|||
18
packages/core/src/location-service-map.ts
Normal file
18
packages/core/src/location-service-map.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Context, Effect, Layer, LayerMap } from "effect"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { Node } from "./effect/app-node"
|
||||
import { Location } from "./location"
|
||||
import type { LocationError, LocationServices } from "./location-services"
|
||||
|
||||
export class Service extends Context.Service<
|
||||
Service,
|
||||
LayerMap.LayerMap<Location.Ref, LocationServices, LocationError>
|
||||
>()("@opencode/example/LocationServiceMap") {
|
||||
static get(ref: Location.Ref) {
|
||||
return Layer.unwrap(Effect.map(Service, (locations) => locations.get(ref)))
|
||||
}
|
||||
}
|
||||
|
||||
export const node = LayerNode.unbound(Service, Node.tags.values.global)
|
||||
|
||||
export * as LocationServiceMap from "./location-service-map"
|
||||
112
packages/core/src/location-services.ts
Normal file
112
packages/core/src/location-services.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { Effect, Layer, LayerMap } from "effect"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { AISDK } from "./aisdk"
|
||||
import { Catalog } from "./catalog"
|
||||
import { CommandV2 } from "./command"
|
||||
import { Config } from "./config"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { Node } from "./effect/app-node"
|
||||
import { FileMutation } from "./file-mutation"
|
||||
import { FileSystem } from "./filesystem"
|
||||
import { FileSystemSearch } from "./filesystem/search"
|
||||
import { Watcher } from "./filesystem/watcher"
|
||||
import { Image } from "./image"
|
||||
import { Integration } from "./integration"
|
||||
import { Location } from "./location"
|
||||
import { LocationMutation } from "./location-mutation"
|
||||
import { LocationServiceMap } from "./location-service-map"
|
||||
import { PermissionV2 } from "./permission"
|
||||
import { PluginV2 } from "./plugin"
|
||||
import { PluginInternal } from "./plugin/internal"
|
||||
import { Policy } from "./policy"
|
||||
import { ProjectCopy } from "./project/copy"
|
||||
import { Pty } from "./pty"
|
||||
import { QuestionV2 } from "./question"
|
||||
import { Reference } from "./reference"
|
||||
import { ReferenceGuidance } from "./reference/guidance"
|
||||
import * as SessionRunnerLLM from "./session/runner/llm"
|
||||
import { SessionRunnerModel } from "./session/runner/model"
|
||||
import { SessionTodo } from "./session/todo"
|
||||
import { SkillV2 } from "./skill"
|
||||
import { SkillGuidance } from "./skill/guidance"
|
||||
import { Snapshot } from "./snapshot"
|
||||
import { SystemContextBuiltIns } from "./system-context/builtins"
|
||||
import { SystemContextRegistry } from "./system-context/registry"
|
||||
import { BuiltInTools } from "./tool/builtins"
|
||||
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||
import { ToolRegistry } from "./tool/registry"
|
||||
import { ToolOutputStore } from "./tool-output-store"
|
||||
|
||||
export { LocationServiceMap } from "./location-service-map"
|
||||
|
||||
export const locationServices = LayerNode.group([
|
||||
Location.node,
|
||||
Policy.node,
|
||||
Config.node,
|
||||
AgentV2.node,
|
||||
CommandV2.node,
|
||||
Reference.node,
|
||||
Integration.node,
|
||||
Catalog.node,
|
||||
AISDK.node,
|
||||
PluginV2.node,
|
||||
PluginInternal.node,
|
||||
ProjectCopy.node,
|
||||
ProjectCopy.refreshNode,
|
||||
FileSystemSearch.node,
|
||||
FileSystem.node,
|
||||
Watcher.node,
|
||||
Pty.node,
|
||||
SkillV2.node,
|
||||
SystemContextRegistry.node,
|
||||
SystemContextBuiltIns.node,
|
||||
LocationMutation.node,
|
||||
FileMutation.node,
|
||||
PermissionV2.node,
|
||||
ToolOutputStore.node,
|
||||
ToolRegistry.node,
|
||||
ToolRegistry.toolsNode,
|
||||
Image.node,
|
||||
SkillGuidance.node,
|
||||
ReferenceGuidance.node,
|
||||
SessionTodo.node,
|
||||
QuestionV2.node,
|
||||
ReadToolFileSystem.node,
|
||||
BuiltInTools.node,
|
||||
SessionRunnerModel.node,
|
||||
Snapshot.node,
|
||||
SessionRunnerLLM.node,
|
||||
])
|
||||
|
||||
export type LocationServices = LayerNode.Output<typeof locationServices>
|
||||
export type LocationError = LayerNode.Error<typeof locationServices>
|
||||
|
||||
export function buildLocationServiceMap(
|
||||
replacements?: ReadonlyMap<Layer.Any, Layer.Any>,
|
||||
): Layer.Layer<LocationServiceMap.Service> {
|
||||
return Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
(ref: Location.Ref) => {
|
||||
const location = LayerNode.hoist(
|
||||
LayerNode.bind(locationServices, Location.node, Location.boundNode(ref)),
|
||||
Node.tags.values.global,
|
||||
)
|
||||
return LayerNode.compile(location.node, replacements).pipe(
|
||||
Layer.fresh,
|
||||
Layer.tap(() =>
|
||||
Effect.logInfo("booting location services", {
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
}),
|
||||
),
|
||||
Layer.provide(LayerNode.compile(location.hoisted, replacements)),
|
||||
)
|
||||
},
|
||||
{ idleTimeToLive: "60 minutes" },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// This is temporary for backwards compatibility
|
||||
export const locationServiceMapLayer = buildLocationServiceMap()
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { Context, Effect, Layer } from "effect"
|
||||
import { Info, Ref, response } from "@opencode-ai/schema/location"
|
||||
import { Project } from "./project"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { makeLocationNode, tags } from "./effect/app-node"
|
||||
|
||||
export * as Location from "./location"
|
||||
|
||||
|
|
@ -12,6 +14,8 @@ export interface Interface extends Info {
|
|||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
|
||||
|
||||
export const node = LayerNode.unbound(Service, tags.values.location)
|
||||
|
||||
export const layer = (ref: Ref) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
|
|
@ -26,3 +30,10 @@ export const layer = (ref: Ref) =>
|
|||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const boundNode = (ref: Ref) =>
|
||||
makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(ref),
|
||||
deps: [Project.node],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import { Hash } from "./util/hash"
|
|||
import { FSUtil } from "./fs-util"
|
||||
import { InstallationChannel, InstallationVersion } from "./installation/version"
|
||||
import { EventV2 } from "./event"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { httpClient } from "./effect/layer-node-platform"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { httpClient } from "./effect/app-node-platform"
|
||||
|
||||
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
|
||||
export type CatalogModelStatus = typeof CatalogModelStatus.Type
|
||||
|
|
@ -244,6 +244,6 @@ export const defaultLayer = layer.pipe(
|
|||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
)
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] })
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] })
|
||||
|
||||
export * as ModelsDev from "./models-dev"
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import { NodeFileSystem } from "@effect/platform-node"
|
|||
import { FSUtil } from "./fs-util"
|
||||
import { Global } from "./global"
|
||||
import { EffectFlock } from "./util/effect-flock"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { filesystem } from "./effect/layer-node-platform"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { filesystem } from "./effect/app-node-platform"
|
||||
import { makeRuntime } from "./effect/runtime"
|
||||
import { NpmConfig } from "./npm-config"
|
||||
|
||||
|
|
@ -253,7 +253,7 @@ export const defaultLayer = layer.pipe(
|
|||
Layer.provide(Global.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
)
|
||||
export const node = LayerNode.make({
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Global.node, filesystem, EffectFlock.node],
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * as PermissionV2 from "./permission"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { EventV2 } from "./event"
|
||||
|
|
@ -300,3 +301,9 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provideMerge(AgentV2.locationLayer))
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [EventV2.node, Location.node, AgentV2.node, SessionStore.node, PermissionSaved.node],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export * as PermissionSaved from "./saved"
|
|||
import { eq } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
import { ProjectV2 } from "../project"
|
||||
import { PermissionTable } from "./sql"
|
||||
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||
|
|
@ -76,3 +77,5 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * as PluginV2 from "./plugin"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect"
|
||||
import type { Plugin as PluginRuntime } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
|
|
@ -150,3 +151,18 @@ export const locationLayer = layer.pipe(
|
|||
Layer.provideMerge(Reference.locationLayer),
|
||||
Layer.provideMerge(SkillV2.locationLayer),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [
|
||||
EventV2.node,
|
||||
AgentV2.node,
|
||||
AISDK.node,
|
||||
Catalog.node,
|
||||
CommandV2.node,
|
||||
Integration.node,
|
||||
Reference.node,
|
||||
SkillV2.node,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
export * as PluginInternal from "./internal"
|
||||
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { httpClient } from "../effect/app-node-platform"
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect, Layer, Scope } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
|
|
@ -58,7 +60,7 @@ export function define<R>(plugin: Plugin<R>) {
|
|||
return plugin
|
||||
}
|
||||
|
||||
export const locationLayer = Layer.effectDiscard(
|
||||
const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const commands = yield* CommandV2.Service
|
||||
|
|
@ -120,9 +122,34 @@ export const locationLayer = Layer.effectDiscard(
|
|||
}),
|
||||
).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
|
||||
}),
|
||||
).pipe(
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(PluginV2.locationLayer),
|
||||
Layer.provideMerge(Config.locationLayer),
|
||||
Layer.provideMerge(FileSystem.locationLayer),
|
||||
Layer.provideMerge(FetchHttpClient.layer),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "plugin-internal",
|
||||
layer,
|
||||
deps: [
|
||||
Catalog.node,
|
||||
CommandV2.node,
|
||||
PluginV2.node,
|
||||
Integration.node,
|
||||
AgentV2.node,
|
||||
Config.node,
|
||||
Location.node,
|
||||
ModelsDev.node,
|
||||
Npm.node,
|
||||
EventV2.node,
|
||||
FSUtil.node,
|
||||
FileSystem.node,
|
||||
Global.node,
|
||||
httpClient,
|
||||
SkillV2.node,
|
||||
Reference.node,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * as Policy from "./policy"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Context, Effect as EffectRuntime, Layer, Schema } from "effect"
|
||||
import { Wildcard } from "./util/wildcard"
|
||||
import { Location } from "./location"
|
||||
|
|
@ -44,3 +45,5 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] })
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { PlatformError } from "effect/PlatformError"
|
|||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { CrossSpawnSpawner } from "./cross-spawn-spawner"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
|
||||
export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()("AppProcessError", {
|
||||
command: Schema.String,
|
||||
|
|
@ -257,6 +257,6 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [CrossSpawnSpawner.node] })
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [CrossSpawnSpawner.node] })
|
||||
|
||||
export * as AppProcess from "./process"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import path from "path"
|
|||
import { AbsolutePath } from "./schema"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Git } from "./git"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { Hash } from "./util/hash"
|
||||
import { ProjectDirectories } from "./project/directories"
|
||||
import { ProjectSchema } from "./project/schema"
|
||||
|
|
@ -134,7 +134,7 @@ export const defaultLayer = layer.pipe(
|
|||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provideMerge(ProjectDirectories.defaultLayer),
|
||||
)
|
||||
export const node = LayerNode.make({
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Git.node, ProjectDirectories.node],
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import path from "path"
|
|||
import { AbsolutePath } from "../schema"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Git } from "../git"
|
||||
import { LayerNode } from "../effect/layer-node"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { Project } from "../project"
|
||||
import { ProjectDirectories } from "./directories"
|
||||
import { makeGitWorktreeStrategy } from "./copy-strategies"
|
||||
|
|
@ -279,8 +279,14 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
export const node = LayerNode.make({
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node],
|
||||
})
|
||||
|
||||
export const refreshNode = makeLocationNode({
|
||||
name: "project-copy-refresh",
|
||||
layer: Layer.effectDiscard(refreshAfterBoot),
|
||||
deps: [node, Location.node],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ export * as ProjectDirectories from "./directories"
|
|||
import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { LayerNode } from "../effect/layer-node"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
import { AbsolutePath, optional } from "../schema"
|
||||
import { ProjectSchema } from "./schema"
|
||||
import { ProjectDirectoryTable } from "./sql"
|
||||
|
|
@ -156,4 +156,4 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] })
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] })
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * as Pty from "./pty"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import type { Disp, Proc } from "#pty"
|
||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||
import { Pty } from "@opencode-ai/schema/pty"
|
||||
|
|
@ -313,3 +314,5 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Location.node, Config.node] })
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { WorkspaceV2 } from "../workspace"
|
|||
import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
|
||||
import { PtyID } from "./schema"
|
||||
import { Cache, Context, Duration, Effect, Layer } from "effect"
|
||||
import { LayerNode } from "../effect/layer-node"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
|
||||
const DEFAULT_TTL = Duration.seconds(60)
|
||||
const CAPACITY = 10_000
|
||||
|
|
@ -54,4 +54,4 @@ export const make = (ttl: Duration.Input = DEFAULT_TTL) =>
|
|||
export const layer = Layer.effect(Service, make())
|
||||
|
||||
export const defaultLayer = layer
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [] })
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [] })
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
export * as PublicEventManifest from "./public-event-manifest"
|
||||
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
|
||||
export const Definitions = EventManifest.ServerDefinitions
|
||||
export const Latest = Event.latest(Definitions)
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
export * as QuestionV2 from "./question"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Context, Deferred, Effect, Layer, Schema } from "effect"
|
||||
import { Question } from "@opencode-ai/schema/question"
|
||||
import { EventV2 } from "./event"
|
||||
|
|
@ -148,3 +149,5 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * as Reference from "./reference"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Context, Effect, Layer, Scope, Types } from "effect"
|
||||
import { Reference } from "@opencode-ai/schema/reference"
|
||||
import { Global } from "./global"
|
||||
|
|
@ -120,3 +121,9 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Global.node, EventV2.node, RepositoryCache.node],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * as ReferenceGuidance from "./guidance"
|
||||
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Reference } from "../reference"
|
||||
import { SystemContext } from "../system-context/index"
|
||||
|
|
@ -64,3 +65,5 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Reference.node] })
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Git } from "./git"
|
|||
import { Global } from "./global"
|
||||
import { Repository } from "./repository"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { EffectFlock } from "./util/effect-flock"
|
||||
|
||||
export type Result = {
|
||||
|
|
@ -229,6 +230,12 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
|||
Layer.provide(Global.defaultLayer),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [EffectFlock.node, FSUtil.node, Git.node, Global.node],
|
||||
})
|
||||
|
||||
function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) {
|
||||
if (!input.reuse) return "cloned" as const
|
||||
if (input.branchMatches === false || input.refresh) return "refreshed" as const
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ export * as Ripgrep from "./ripgrep"
|
|||
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { Entry, Match } from "@opencode-ai/schema/filesystem"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { AppProcess, collectStream, waitForAbort } from "./process"
|
||||
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
||||
import { RipgrepBinary } from "./ripgrep/binary"
|
||||
|
|
@ -279,4 +279,4 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Layer.merge(RipgrepBinary.defaultLayer, AppProcess.defaultLayer)))
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] })
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] })
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/
|
|||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { CrossSpawnSpawner } from "../cross-spawn-spawner"
|
||||
import { LayerNode } from "../effect/layer-node"
|
||||
import { httpClient } from "../effect/layer-node-platform"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
import { httpClient } from "../effect/app-node-platform"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Global } from "../global"
|
||||
import { which } from "../util/which"
|
||||
|
|
@ -130,7 +130,7 @@ export namespace RipgrepBinary {
|
|||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
)
|
||||
|
||||
export const node = LayerNode.make({
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, httpClient, CrossSpawnSpawner.node],
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ import { fromRow } from "./session/info"
|
|||
import { SessionRunner } from "./session/runner/index"
|
||||
import { SessionStore } from "./session/store"
|
||||
import { SessionExecution } from "./session/execution"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { LocationServiceMap } from "./location-service-map"
|
||||
import { MessageDecodeError } from "./session/error"
|
||||
import { SessionEvent } from "./session/event"
|
||||
import { SessionInput } from "./session/input"
|
||||
|
|
@ -34,6 +36,7 @@ import { Snapshot } from "./snapshot"
|
|||
import { SessionRevert } from "./session/revert"
|
||||
import { Revert } from "@opencode-ai/schema/revert"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest"
|
||||
|
||||
export const RevertState = Revert.State
|
||||
export type RevertState = Revert.State
|
||||
|
|
@ -134,15 +137,17 @@ export interface Interface {
|
|||
sessionID: SessionSchema.ID
|
||||
after?: number
|
||||
}) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError>
|
||||
readonly history: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
after?: number
|
||||
limit: number
|
||||
}) => Effect.Effect<{ events: ReadonlyArray<SessionEvent.DurableEvent>; hasMore: boolean }, NotFoundError>
|
||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchModel: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
model: ModelV2.Ref
|
||||
}) => Effect.Effect<void, NotFoundError>
|
||||
readonly rename: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
title: string
|
||||
}) => Effect.Effect<void, NotFoundError>
|
||||
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
|
||||
readonly prompt: (input: {
|
||||
id?: SessionMessage.ID
|
||||
sessionID: SessionSchema.ID
|
||||
|
|
@ -180,301 +185,295 @@ export interface Interface {
|
|||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
|
||||
|
||||
export const layer = Layer.unwrap(
|
||||
Effect.promise(() => import("./location-layer")).pipe(
|
||||
Effect.map(({ LocationServiceMap }) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const db = database.db
|
||||
const events = yield* EventV2.Service
|
||||
const projects = yield* ProjectV2.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
Effect.mapError(
|
||||
() =>
|
||||
new MessageDecodeError({
|
||||
sessionID: SessionSchema.ID.make(row.session_id),
|
||||
messageID: SessionMessage.ID.make(row.id),
|
||||
}),
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const db = database.db
|
||||
const events = yield* EventV2.Service
|
||||
const projects = yield* ProjectV2.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
Effect.mapError(
|
||||
() =>
|
||||
new MessageDecodeError({
|
||||
sessionID: SessionSchema.ID.make(row.session_id),
|
||||
messageID: SessionMessage.ID.make(row.id),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const result = Service.of({
|
||||
create: Effect.fn("V2Session.create")(function* (input) {
|
||||
const sessionID = input.id ?? SessionSchema.ID.create()
|
||||
const recorded = yield* store.get(sessionID)
|
||||
if (recorded) return recorded
|
||||
const project = yield* projects.resolve(input.location.directory)
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const now = Date.now()
|
||||
const info = SessionV1.SessionInfo.make({
|
||||
id: sessionID,
|
||||
slug: Slug.create(),
|
||||
version: InstallationVersion,
|
||||
projectID: project.id,
|
||||
directory: input.location.directory,
|
||||
path: path.relative(project.directory, input.location.directory).replaceAll("\\", "/"),
|
||||
workspaceID: input.location.workspaceID ? WorkspaceV2.ID.make(input.location.workspaceID) : undefined,
|
||||
title: `New session - ${new Date(now).toISOString()}`,
|
||||
agent: input.agent,
|
||||
model: input.model
|
||||
? {
|
||||
id: ModelV2.ID.make(input.model.id),
|
||||
providerID: input.model.providerID,
|
||||
variant: input.model.variant,
|
||||
}
|
||||
: undefined,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: now, updated: now },
|
||||
})
|
||||
const projected = yield* events
|
||||
.publish(SessionV1.Event.Created, { sessionID, info }, { location: input.location })
|
||||
.pipe(
|
||||
Effect.as({ type: "created" } as const),
|
||||
Effect.catchDefect((defect) => {
|
||||
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
|
||||
return Effect.die(defect)
|
||||
}
|
||||
// Concurrent creation lost the projection race. The existing Session identity wins.
|
||||
return store
|
||||
.get(sessionID)
|
||||
.pipe(
|
||||
Effect.flatMap((session) =>
|
||||
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
if (projected.type === "existing") return projected.session
|
||||
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
|
||||
return yield* result.get(sessionID).pipe(Effect.orDie)
|
||||
}),
|
||||
get: Effect.fn("V2Session.get")(function* (sessionID) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* new NotFoundError({ sessionID })
|
||||
return session
|
||||
}),
|
||||
list: Effect.fn("V2Session.list")(function* (input = {}) {
|
||||
const direction = input.anchor?.direction ?? "next"
|
||||
const requestedOrder = input.order ?? "desc"
|
||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||
const sortColumn = SessionTable.time_created
|
||||
const conditions: SQL[] = []
|
||||
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
|
||||
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
|
||||
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
|
||||
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||
if (input.anchor) {
|
||||
conditions.push(
|
||||
order === "asc"
|
||||
? or(
|
||||
gt(sortColumn, input.anchor.time),
|
||||
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
|
||||
)!
|
||||
: or(
|
||||
lt(sortColumn, input.anchor.time),
|
||||
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
|
||||
)!,
|
||||
)
|
||||
}
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(
|
||||
order === "asc" ? asc(sortColumn) : desc(sortColumn),
|
||||
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
|
||||
)
|
||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
|
||||
}),
|
||||
messages: Effect.fn("V2Session.messages")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
const direction = input.cursor?.direction ?? "next"
|
||||
const requestedOrder = input.order ?? "desc"
|
||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||
const anchor = input.cursor
|
||||
? yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
: undefined
|
||||
if (input.cursor && !anchor) return []
|
||||
const boundary = anchor
|
||||
? order === "asc"
|
||||
? gt(SessionMessageTable.seq, anchor.seq)
|
||||
: lt(SessionMessageTable.seq, anchor.seq)
|
||||
: undefined
|
||||
const where = boundary
|
||||
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
|
||||
: eq(SessionMessageTable.session_id, input.sessionID)
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(where)
|
||||
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
|
||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, decode)
|
||||
}),
|
||||
message: Effect.fn("V2Session.message")(function* (input) {
|
||||
const stored = yield* store.message(input.messageID)
|
||||
return stored?.sessionID === input.sessionID ? stored.message : undefined
|
||||
}),
|
||||
context: Effect.fn("V2Session.context")(function* (sessionID) {
|
||||
yield* result.get(sessionID)
|
||||
return yield* store.context(sessionID)
|
||||
}),
|
||||
events: (input) =>
|
||||
Stream.unwrap(
|
||||
result
|
||||
.get(input.sessionID)
|
||||
.pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
|
||||
).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
|
||||
history: Effect.fn("V2Session.history")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
return yield* EventV2.readAggregate(db, {
|
||||
...input,
|
||||
aggregateID: input.sessionID,
|
||||
manifest: SessionDurable,
|
||||
})
|
||||
}),
|
||||
prompt: Effect.fn("V2Session.prompt")((input) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
yield* result.get(input.sessionID)
|
||||
const prompt = resolvePrompt(input.prompt)
|
||||
const messageID = input.id ?? SessionMessage.ID.create()
|
||||
const delivery = input.delivery ?? "steer"
|
||||
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
|
||||
const admitted = yield* SessionInput.admit(db, events, {
|
||||
id: messageID,
|
||||
sessionID: input.sessionID,
|
||||
prompt,
|
||||
delivery,
|
||||
}).pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof SessionInput.LifecycleConflict
|
||||
? new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||
: Effect.die(defect),
|
||||
),
|
||||
)
|
||||
|
||||
const result = Service.of({
|
||||
create: Effect.fn("V2Session.create")(function* (input) {
|
||||
const sessionID = input.id ?? SessionSchema.ID.create()
|
||||
const recorded = yield* store.get(sessionID)
|
||||
if (recorded) return recorded
|
||||
const project = yield* projects.resolve(input.location.directory)
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const now = Date.now()
|
||||
const info = SessionV1.SessionInfo.make({
|
||||
id: sessionID,
|
||||
slug: Slug.create(),
|
||||
version: InstallationVersion,
|
||||
projectID: project.id,
|
||||
directory: input.location.directory,
|
||||
path: path.relative(project.directory, input.location.directory).replaceAll("\\", "/"),
|
||||
workspaceID: input.location.workspaceID ? WorkspaceV2.ID.make(input.location.workspaceID) : undefined,
|
||||
title: `New session - ${new Date(now).toISOString()}`,
|
||||
agent: input.agent,
|
||||
model: input.model
|
||||
? {
|
||||
id: ModelV2.ID.make(input.model.id),
|
||||
providerID: input.model.providerID,
|
||||
variant: input.model.variant,
|
||||
}
|
||||
: undefined,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: now, updated: now },
|
||||
})
|
||||
const projected = yield* events
|
||||
.publish(SessionV1.Event.Created, { sessionID, info }, { location: input.location })
|
||||
.pipe(
|
||||
Effect.as({ type: "created" } as const),
|
||||
Effect.catchDefect((defect) => {
|
||||
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
|
||||
return Effect.die(defect)
|
||||
}
|
||||
// Concurrent creation lost the projection race. The existing Session identity wins.
|
||||
return store
|
||||
.get(sessionID)
|
||||
.pipe(
|
||||
Effect.flatMap((session) =>
|
||||
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
if (projected.type === "existing") return projected.session
|
||||
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
|
||||
return yield* result.get(sessionID).pipe(Effect.orDie)
|
||||
}),
|
||||
get: Effect.fn("V2Session.get")(function* (sessionID) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* new NotFoundError({ sessionID })
|
||||
return session
|
||||
}),
|
||||
list: Effect.fn("V2Session.list")(function* (input = {}) {
|
||||
const direction = input.anchor?.direction ?? "next"
|
||||
const requestedOrder = input.order ?? "desc"
|
||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||
const sortColumn = SessionTable.time_created
|
||||
const conditions: SQL[] = []
|
||||
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
|
||||
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
|
||||
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
|
||||
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||
if (input.anchor) {
|
||||
conditions.push(
|
||||
order === "asc"
|
||||
? or(
|
||||
gt(sortColumn, input.anchor.time),
|
||||
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
|
||||
)!
|
||||
: or(
|
||||
lt(sortColumn, input.anchor.time),
|
||||
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
|
||||
)!,
|
||||
)
|
||||
}
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(
|
||||
order === "asc" ? asc(sortColumn) : desc(sortColumn),
|
||||
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
|
||||
)
|
||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
|
||||
}),
|
||||
messages: Effect.fn("V2Session.messages")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
const direction = input.cursor?.direction ?? "next"
|
||||
const requestedOrder = input.order ?? "desc"
|
||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||
const anchor = input.cursor
|
||||
? yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, input.sessionID),
|
||||
eq(SessionMessageTable.id, input.cursor.id),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
: undefined
|
||||
if (input.cursor && !anchor) return []
|
||||
const boundary = anchor
|
||||
? order === "asc"
|
||||
? gt(SessionMessageTable.seq, anchor.seq)
|
||||
: lt(SessionMessageTable.seq, anchor.seq)
|
||||
: undefined
|
||||
const where = boundary
|
||||
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
|
||||
: eq(SessionMessageTable.session_id, input.sessionID)
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(where)
|
||||
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
|
||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, decode)
|
||||
}),
|
||||
message: Effect.fn("V2Session.message")(function* (input) {
|
||||
const stored = yield* store.message(input.messageID)
|
||||
return stored?.sessionID === input.sessionID ? stored.message : undefined
|
||||
}),
|
||||
context: Effect.fn("V2Session.context")(function* (sessionID) {
|
||||
yield* result.get(sessionID)
|
||||
return yield* store.context(sessionID)
|
||||
}),
|
||||
events: (input) =>
|
||||
Stream.unwrap(
|
||||
result
|
||||
.get(input.sessionID)
|
||||
.pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
|
||||
).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
|
||||
prompt: Effect.fn("V2Session.prompt")((input) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
yield* result.get(input.sessionID)
|
||||
const prompt = resolvePrompt(input.prompt)
|
||||
const messageID = input.id ?? SessionMessage.ID.create()
|
||||
const delivery = input.delivery ?? "steer"
|
||||
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
|
||||
const admitted = yield* SessionInput.admit(db, events, {
|
||||
id: messageID,
|
||||
sessionID: input.sessionID,
|
||||
prompt,
|
||||
delivery,
|
||||
}).pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof SessionInput.LifecycleConflict
|
||||
? new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||
: Effect.die(defect),
|
||||
),
|
||||
)
|
||||
if (!SessionInput.equivalent(admitted, expected))
|
||||
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||
if (input.resume !== false) yield* execution.wake(admitted.sessionID)
|
||||
return admitted
|
||||
}),
|
||||
),
|
||||
),
|
||||
shell: Effect.fn("V2Session.shell")(function* () {
|
||||
return yield* new OperationUnavailableError({ operation: "shell" })
|
||||
}),
|
||||
skill: Effect.fn("V2Session.skill")(function* () {
|
||||
return yield* new OperationUnavailableError({ operation: "skill" })
|
||||
}),
|
||||
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
yield* events.publish(SessionEvent.AgentSwitched, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: yield* DateTime.now,
|
||||
agent: input.agent,
|
||||
})
|
||||
}),
|
||||
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
if (
|
||||
session.model?.providerID === input.model.providerID &&
|
||||
session.model.id === input.model.id &&
|
||||
(session.model.variant ?? "default") === (input.model.variant ?? "default")
|
||||
)
|
||||
return
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: yield* DateTime.now,
|
||||
model: input.model,
|
||||
})
|
||||
}),
|
||||
rename: Effect.fn("V2Session.rename")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
yield* events.publish(SessionEvent.Renamed, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
title: input.title,
|
||||
})
|
||||
}),
|
||||
compact: Effect.fn("V2Session.compact")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
return yield* new OperationUnavailableError({ operation: "compact" })
|
||||
}),
|
||||
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
|
||||
yield* result.get(sessionID)
|
||||
return yield* new OperationUnavailableError({ operation: "wait" })
|
||||
}),
|
||||
active: execution.active,
|
||||
resume: Effect.fn("V2Session.resume")(function* (sessionID) {
|
||||
yield* result.get(sessionID)
|
||||
yield* execution.resume(sessionID)
|
||||
}),
|
||||
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
|
||||
Effect.uninterruptible(execution.interrupt(sessionID)),
|
||||
),
|
||||
revert: {
|
||||
stage: Effect.fn("V2Session.revert.stage")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
if ((yield* execution.active).has(input.sessionID))
|
||||
return yield* new BusyError({ sessionID: input.sessionID })
|
||||
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
|
||||
Effect.provideService(Database.Service, database),
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provide(locations.get(session.location)),
|
||||
)
|
||||
}),
|
||||
clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) {
|
||||
const session = yield* result.get(sessionID)
|
||||
if ((yield* execution.active).has(sessionID))
|
||||
return yield* new BusyError({ sessionID })
|
||||
yield* SessionRevert.clear(session).pipe(
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provide(locations.get(session.location)),
|
||||
)
|
||||
}),
|
||||
commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) {
|
||||
const session = yield* result.get(sessionID)
|
||||
if ((yield* execution.active).has(sessionID))
|
||||
return yield* new BusyError({ sessionID })
|
||||
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
return result
|
||||
}),
|
||||
if (!SessionInput.equivalent(admitted, expected))
|
||||
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||
if (input.resume !== false) yield* execution.wake(admitted.sessionID)
|
||||
return admitted
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
shell: Effect.fn("V2Session.shell")(function* () {
|
||||
return yield* new OperationUnavailableError({ operation: "shell" })
|
||||
}),
|
||||
skill: Effect.fn("V2Session.skill")(function* () {
|
||||
return yield* new OperationUnavailableError({ operation: "skill" })
|
||||
}),
|
||||
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
yield* events.publish(SessionEvent.AgentSwitched, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: yield* DateTime.now,
|
||||
agent: input.agent,
|
||||
})
|
||||
}),
|
||||
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
if (
|
||||
session.model?.providerID === input.model.providerID &&
|
||||
session.model.id === input.model.id &&
|
||||
(session.model.variant ?? "default") === (input.model.variant ?? "default")
|
||||
)
|
||||
return
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: yield* DateTime.now,
|
||||
model: input.model,
|
||||
})
|
||||
}),
|
||||
rename: Effect.fn("V2Session.rename")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
yield* events.publish(SessionEvent.Renamed, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
title: input.title,
|
||||
})
|
||||
}),
|
||||
compact: Effect.fn("V2Session.compact")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
return yield* new OperationUnavailableError({ operation: "compact" })
|
||||
}),
|
||||
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
|
||||
yield* result.get(sessionID)
|
||||
return yield* new OperationUnavailableError({ operation: "wait" })
|
||||
}),
|
||||
active: execution.active,
|
||||
resume: Effect.fn("V2Session.resume")(function* (sessionID) {
|
||||
yield* result.get(sessionID)
|
||||
yield* execution.resume(sessionID)
|
||||
}),
|
||||
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
|
||||
Effect.uninterruptible(execution.interrupt(sessionID)),
|
||||
),
|
||||
revert: {
|
||||
stage: Effect.fn("V2Session.revert.stage")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
if ((yield* execution.active).has(input.sessionID))
|
||||
return yield* new BusyError({ sessionID: input.sessionID })
|
||||
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
|
||||
Effect.provideService(Database.Service, database),
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provide(locations.get(session.location)),
|
||||
)
|
||||
}),
|
||||
clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) {
|
||||
const session = yield* result.get(sessionID)
|
||||
if ((yield* execution.active).has(sessionID)) return yield* new BusyError({ sessionID })
|
||||
yield* SessionRevert.clear(session).pipe(
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provide(locations.get(session.location)),
|
||||
)
|
||||
}),
|
||||
commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) {
|
||||
const session = yield* result.get(sessionID)
|
||||
if ((yield* execution.active).has(sessionID)) return yield* new BusyError({ sessionID })
|
||||
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
return result
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.unwrap(Effect.promise(() => import("./location-layer")).pipe(Effect.map((m) => m.LocationServiceMap.layer))),
|
||||
),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(SessionProjector.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
|
|
@ -496,3 +495,17 @@ const resolvePrompt = (input: PromptInput.Prompt) =>
|
|||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer.pipe(Layer.orDie),
|
||||
deps: [
|
||||
Database.node,
|
||||
EventV2.node,
|
||||
ProjectV2.node,
|
||||
SessionExecution.node,
|
||||
SessionStore.node,
|
||||
LocationServiceMap.node,
|
||||
SessionProjector.node,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
export * as SessionExecution from "./execution"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { LayerNode } from "../effect/layer-node"
|
||||
import { Node } from "../effect/app-node"
|
||||
import { SessionRunner } from "./runner/index"
|
||||
import { SessionSchema } from "./schema"
|
||||
|
||||
|
|
@ -18,6 +20,8 @@ export interface Interface {
|
|||
/** Routes execution from a Session ID to the runner owned by that Session's Location. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionExecution") {}
|
||||
|
||||
export const node = LayerNode.unbound(Service, Node.tags.values.global)
|
||||
|
||||
/** Low-level compatibility layer for callers that only need durable Session recording. */
|
||||
export const noopLayer = Layer.succeed(
|
||||
Service,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Cause, Effect, Layer } from "effect"
|
||||
import { LocationServiceMap } from "../../location-layer"
|
||||
import { LocationServiceMap } from "../../location-service-map"
|
||||
import { makeGlobalNode } from "../../effect/app-node"
|
||||
import { SessionRunCoordinator } from "../run-coordinator"
|
||||
import { SessionRunner } from "../runner"
|
||||
import { SessionSchema } from "../schema"
|
||||
|
|
@ -11,7 +12,7 @@ export const layer = Layer.effect(
|
|||
SessionExecution.Service,
|
||||
Effect.gen(function* () {
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
|
||||
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
|
||||
const session = yield* store.get(sessionID)
|
||||
|
|
@ -37,3 +38,11 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(SessionStore.defaultLayer))
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: SessionExecution.Service,
|
||||
layer,
|
||||
deps: [SessionStore.node, LocationServiceMap.node],
|
||||
})
|
||||
|
||||
export * as SessionExecutionLocal from "./local"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { and, desc, eq, gt, or, sql } from "drizzle-orm"
|
|||
import { DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { LayerNode } from "../effect/layer-node"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionV1 } from "../v1/session"
|
||||
import { WorkspaceTable } from "../control-plane/workspace.sql"
|
||||
|
|
@ -464,4 +464,4 @@ export const layer = Layer.effectDiscard(
|
|||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer))
|
||||
export const node = LayerNode.make({ name: "session-projector", layer, deps: [EventV2.node, Database.node] })
|
||||
export const node = makeGlobalNode({ name: "session-projector", layer, deps: [EventV2.node, Database.node] })
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ import { createLLMEventPublisher } from "./publish-llm-event"
|
|||
import { toLLMMessages } from "./to-llm-message"
|
||||
import { MAX_STEPS_PROMPT } from "./max-steps"
|
||||
import { Snapshot } from "../../snapshot"
|
||||
import { makeLocationNode } from "../../effect/app-node"
|
||||
import { llmClient } from "../../effect/app-node-platform"
|
||||
|
||||
/**
|
||||
* Runs one durable coding-agent Session until it settles.
|
||||
|
|
@ -404,3 +406,23 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [
|
||||
EventV2.node,
|
||||
llmClient,
|
||||
AgentV2.node,
|
||||
ToolRegistry.node,
|
||||
SessionRunnerModel.node,
|
||||
SessionStore.node,
|
||||
Location.node,
|
||||
SystemContextRegistry.node,
|
||||
SkillGuidance.node,
|
||||
ReferenceGuidance.node,
|
||||
Config.node,
|
||||
Snapshot.node,
|
||||
Database.node,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * as SessionRunnerModel from "./model"
|
||||
|
||||
import { makeLocationNode } from "../../effect/app-node"
|
||||
import { type Model } from "@opencode-ai/llm"
|
||||
import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages"
|
||||
import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat"
|
||||
|
|
@ -213,3 +214,5 @@ export const locationLayer = Layer.effect(
|
|||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [Catalog.node, Integration.node] })
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export * as SessionStore from "./store"
|
|||
import { eq } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
import { SessionHistory } from "./history"
|
||||
import { MessageDecodeError } from "./error"
|
||||
import { SessionMessage } from "./message"
|
||||
|
|
@ -60,3 +61,5 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { asc, eq } from "drizzle-orm"
|
|||
import { Context, Effect, Layer } from "effect"
|
||||
import { SessionTodo } from "@opencode-ai/schema/session-todo"
|
||||
import { Database } from "../database/database"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { EventV2 } from "../event"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { TodoTable } from "./sql"
|
||||
|
|
@ -75,3 +76,5 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer))
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Database.node] })
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * as SkillV2 from "./skill"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
|
|
@ -129,3 +130,5 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provide(SkillDiscovery.defaultLayer))
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [SkillDiscovery.node, FSUtil.node] })
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { Context, Effect, Layer, Schedule, Schema } from "effect"
|
|||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Global } from "../global"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
import { httpClient } from "../effect/app-node-platform"
|
||||
import { AbsolutePath } from "../schema"
|
||||
|
||||
const skillConcurrency = 4
|
||||
|
|
@ -213,3 +215,5 @@ export const defaultLayer = layer.pipe(
|
|||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Global.defaultLayer),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [httpClient, FSUtil.node, Global.node] })
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * as SkillGuidance from "./guidance"
|
||||
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { PermissionV2 } from "../permission"
|
||||
|
|
@ -71,3 +72,5 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [SkillV2.node] })
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * as Snapshot from "./snapshot"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Config } from "./config"
|
||||
|
|
@ -228,6 +229,12 @@ export const layer = Layer.effect(
|
|||
|
||||
export const locationLayer = layer.pipe(Layer.provideMerge(Config.locationLayer))
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, FSUtil.node, Git.node, Global.node, Location.node],
|
||||
})
|
||||
|
||||
export const noopLayer = Layer.succeed(
|
||||
Service,
|
||||
Service.of({
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
export * as SystemContextBuiltIns from "./builtins"
|
||||
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { Location } from "../location"
|
||||
import { SystemContext } from "./index"
|
||||
import { InstructionContext } from "../instruction-context"
|
||||
import { SystemContextRegistry } from "./registry"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Global } from "../global"
|
||||
|
||||
const builtIns = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -45,3 +48,9 @@ export const layer = Layer.mergeAll(builtIns, InstructionContext.layer).pipe(
|
|||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "system-context-builtins",
|
||||
layer,
|
||||
deps: [Location.node, SystemContextRegistry.node, InstructionContext.node, FSUtil.node, Global.node],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ export * as SystemContextRegistry from "./registry"
|
|||
|
||||
import { Context, Effect, Layer, Ref, Scope } from "effect"
|
||||
import { SystemContext } from "./index"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
|
||||
export interface Entry {
|
||||
readonly key: SystemContext.Key
|
||||
|
|
@ -44,3 +45,5 @@ export const layer = Layer.effect(
|
|||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effe
|
|||
import { Config } from "./config"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Global } from "./global"
|
||||
import { makeGlobalNode, makeLocationNode } from "./effect/app-node"
|
||||
import { SessionSchema } from "./session/schema"
|
||||
import { Identifier } from "./util/identifier"
|
||||
import type { ToolOutput } from "@opencode-ai/llm"
|
||||
|
|
@ -193,6 +194,8 @@ export const layer = Layer.effect(
|
|||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.defaultLayer))
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node, Config.node] })
|
||||
|
||||
/** Runs retention scanning once globally rather than once per active Location. */
|
||||
export const cleanupLayer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -202,3 +205,5 @@ export const cleanupLayer = Layer.effectDiscard(
|
|||
)
|
||||
|
||||
export const defaultCleanupLayer = Layer.merge(defaultLayer, cleanupLayer.pipe(Layer.provide(defaultLayer)))
|
||||
|
||||
export const cleanupNode = makeGlobalNode({ name: "tool-output-cleanup", layer: defaultCleanupLayer, deps: [] })
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export * as ApplicationTools from "./application-tools"
|
|||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { State } from "../state"
|
||||
import { Tool } from "./tool"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
|
||||
type Data = {
|
||||
readonly entries: Map<string, Entry>
|
||||
|
|
@ -52,3 +53,5 @@ export const layer = Layer.effect(
|
|||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * as BuiltInTools from "./builtins"
|
||||
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { Layer } from "effect"
|
||||
import { BashTool } from "./bash"
|
||||
import { ApplyPatchTool } from "./apply-patch"
|
||||
|
|
@ -14,6 +15,20 @@ import { TodoWriteTool } from "./todowrite"
|
|||
import { WebFetchTool } from "./webfetch"
|
||||
import { WebSearchTool } from "./websearch"
|
||||
import { WriteTool } from "./write"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { AppProcess } from "../process"
|
||||
import { Config } from "../config"
|
||||
import { Location } from "../location"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { Image } from "../image"
|
||||
import { QuestionV2 } from "../question"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { SessionTodo } from "../session/todo"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { httpClient } from "../effect/app-node-platform"
|
||||
|
||||
/**
|
||||
* Composes only the shipped Location-scoped built-in tool transforms.
|
||||
|
|
@ -42,3 +57,25 @@ export const locationLayer = Layer.mergeAll(
|
|||
WebSearchTool.layer.pipe(Layer.provide(WebSearchTool.defaultConfigLayer)),
|
||||
WriteTool.layer,
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "built-in-tools",
|
||||
layer: locationLayer,
|
||||
deps: [
|
||||
ToolRegistry.toolsNode,
|
||||
FSUtil.node,
|
||||
AppProcess.node,
|
||||
Config.node,
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
FileMutation.node,
|
||||
PermissionV2.node,
|
||||
Ripgrep.node,
|
||||
Image.node,
|
||||
QuestionV2.node,
|
||||
SkillV2.node,
|
||||
SessionTodo.node,
|
||||
ReadToolFileSystem.node,
|
||||
httpClient,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { pathToFileURL } from "url"
|
|||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { AbsolutePath, PositiveInt, RelativePath } from "../schema"
|
||||
|
||||
export const MAX_READ_LINES = 2_000
|
||||
|
|
@ -361,3 +362,5 @@ export const layer = Layer.effect(
|
|||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { Wildcard } from "../util/wildcard"
|
|||
import { ApplicationTools } from "./application-tools"
|
||||
import { definition, permission, settle, validateName, type AnyTool, type RegistrationError } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
|
||||
export type ExecuteInput = {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
|
|
@ -137,3 +138,15 @@ export const defaultLayer = layer.pipe(
|
|||
Layer.provide(ApplicationTools.layer),
|
||||
Layer.provide(ToolOutputStore.defaultLayer),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [ApplicationTools.node, ToolOutputStore.node],
|
||||
})
|
||||
|
||||
export const toolsNode = makeLocationNode({
|
||||
service: Tools.Service,
|
||||
layer,
|
||||
deps: [ApplicationTools.node, ToolOutputStore.node],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type { FileSystem, Scope } from "effect"
|
|||
import type { PlatformError } from "effect/PlatformError"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Global } from "../global"
|
||||
import { LayerNode } from "../effect/layer-node"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
import { Hash } from "./hash"
|
||||
|
||||
export namespace EffectFlock {
|
||||
|
|
@ -281,5 +281,5 @@ export namespace EffectFlock {
|
|||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.layer))
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [Global.node, FSUtil.node] })
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Global.node, FSUtil.node] })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@ import { Effect, Exit, Stream } from "effect"
|
|||
import type * as PlatformError from "effect/PlatformError"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const live = CrossSpawnSpawner.defaultLayer
|
||||
const live = LayerNode.compile(CrossSpawnSpawner.node)
|
||||
const fx = testEffect(live)
|
||||
|
||||
function js(code: string, opts?: ChildProcess.CommandOptions) {
|
||||
|
|
|
|||
136
packages/core/test/effect/layer-node/layer-node-types.test.ts
Normal file
136
packages/core/test/effect/layer-node/layer-node-types.test.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { test } from "bun:test"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
||||
|
||||
class A extends Context.Service<A, {}>()("test/LayerNodeA") {}
|
||||
class B extends Context.Service<B, {}>()("test/LayerNodeB") {}
|
||||
class C extends Context.Service<C, {}>()("test/LayerNodeC") {}
|
||||
class LayerError {
|
||||
readonly _tag = "LayerError"
|
||||
}
|
||||
class OtherError {
|
||||
readonly _tag = "OtherError"
|
||||
}
|
||||
|
||||
const tags = LayerNode.tags({ app: [] })
|
||||
const make = tags.make("app")
|
||||
const build = <A, E>(root: LayerNode.Node<A, E, any>) => LayerNode.compile(root) as Layer.Layer<A, E>
|
||||
const aLayer = Layer.succeed(A, A.of({}))
|
||||
const bLayer = Layer.effect(B, Effect.as(A, B.of({})))
|
||||
const cLayer = Layer.effect(
|
||||
C,
|
||||
Effect.gen(function* () {
|
||||
yield* A
|
||||
yield* B
|
||||
return C.of({})
|
||||
}),
|
||||
)
|
||||
const failingA = Layer.effect(A, Effect.fail(new LayerError()))
|
||||
const a = make({ service: A, layer: aLayer, deps: [] })
|
||||
const b = make({ service: B, layer: bLayer, deps: [a] })
|
||||
const c = make({ service: C, layer: cLayer, deps: [a, b] })
|
||||
const failing = make({ service: A, layer: failingA, deps: [] })
|
||||
const dependent = make({ service: B, layer: bLayer, deps: [failing] })
|
||||
const inputA = LayerNode.unbound(A, tags.values.app)
|
||||
const inputDependent = make({ service: B, layer: bLayer, deps: [inputA] })
|
||||
|
||||
make({ name: "manual-a", layer: aLayer, deps: [] })
|
||||
|
||||
// @ts-expect-error A node must have a service or name
|
||||
make({ layer: aLayer, deps: [] })
|
||||
|
||||
// @ts-expect-error Service and name are mutually exclusive
|
||||
make({ service: A, name: "a", layer: aLayer, deps: [] })
|
||||
|
||||
// @ts-expect-error B requires A
|
||||
make({ service: B, layer: bLayer, deps: [] })
|
||||
|
||||
// @ts-expect-error C requires A and B
|
||||
make({ service: C, layer: cLayer, deps: [a] })
|
||||
|
||||
const closed = build(LayerNode.group([c]))
|
||||
const closedWithError = build(LayerNode.group([dependent]))
|
||||
const checkClosed: Layer.Layer<C, never, never> = closed
|
||||
const checkError: Layer.Layer<B, LayerError, never> = closedWithError
|
||||
void checkClosed
|
||||
void checkError
|
||||
|
||||
LayerNode.replace(aLayer, Layer.succeed(A, A.of({})))
|
||||
|
||||
// @ts-expect-error Replacement must provide A
|
||||
LayerNode.replace(aLayer, Layer.succeed(B, B.of({})))
|
||||
|
||||
// @ts-expect-error Replacement cannot introduce a new error
|
||||
LayerNode.replace(aLayer, Layer.effect(A, Effect.fail(new OtherError())))
|
||||
|
||||
// @ts-expect-error Replacement must be closed
|
||||
LayerNode.replace(bLayer, bLayer)
|
||||
|
||||
class TagA extends Context.Service<TagA, {}>()("test/TagA") {}
|
||||
class TagB extends Context.Service<TagB, {}>()("test/TagB") {}
|
||||
class TagC extends Context.Service<TagC, {}>()("test/TagC") {}
|
||||
|
||||
const scopedTags = LayerNode.tags({ request: ["global"], global: [] })
|
||||
const request = scopedTags.make("request")
|
||||
const global = scopedTags.make("global")
|
||||
const globalA = global({ service: TagA, layer: Layer.succeed(TagA, TagA.of({})), deps: [] })
|
||||
const requestA = request({ service: TagA, layer: Layer.succeed(TagA, TagA.of({})), deps: [] })
|
||||
const requestB = request({ service: TagB, layer: Layer.succeed(TagB, TagB.of({})), deps: [] })
|
||||
const tagBLayer = Layer.effect(TagB, Effect.as(TagA, TagB.of({})))
|
||||
const tagCLayer = Layer.effect(
|
||||
TagC,
|
||||
Effect.gen(function* () {
|
||||
yield* TagA
|
||||
yield* TagB
|
||||
return TagC.of({})
|
||||
}),
|
||||
)
|
||||
|
||||
request({ service: TagB, layer: tagBLayer, deps: [globalA] })
|
||||
request({ service: TagC, layer: tagCLayer, deps: [globalA, requestB] })
|
||||
request({ service: TagC, layer: tagCLayer, deps: [LayerNode.group([globalA, requestB])] })
|
||||
|
||||
// @ts-expect-error Tag configuration can only reference declared tags
|
||||
LayerNode.tags({ request: ["missing"], global: [] })
|
||||
|
||||
// @ts-expect-error An unrelated dependency cannot satisfy TagA
|
||||
request({ service: TagB, layer: tagBLayer, deps: [requestB] })
|
||||
|
||||
// @ts-expect-error Providing only TagA leaves TagB missing
|
||||
request({ service: TagC, layer: tagCLayer, deps: [globalA] })
|
||||
|
||||
// @ts-expect-error Providing only TagB leaves TagA missing
|
||||
request({ service: TagC, layer: tagCLayer, deps: [requestB] })
|
||||
|
||||
// @ts-expect-error Duplicate TagA providers still leave TagB missing
|
||||
request({ service: TagC, layer: tagCLayer, deps: [globalA, requestA] })
|
||||
|
||||
// @ts-expect-error A group with only TagA still leaves TagB missing
|
||||
request({ service: TagC, layer: tagCLayer, deps: [LayerNode.group([globalA])] })
|
||||
|
||||
// @ts-expect-error Global cannot depend on request
|
||||
global({ service: TagB, layer: tagBLayer, deps: [requestA] })
|
||||
|
||||
// @ts-expect-error Groups preserve their child tags
|
||||
global({ service: TagB, layer: tagBLayer, deps: [LayerNode.group([requestA])] })
|
||||
|
||||
class ScopedA extends Context.Service<ScopedA, {}>()("test/ScopedA") {}
|
||||
class ScopedB extends Context.Service<ScopedB, {}>()("test/ScopedB") {}
|
||||
|
||||
const scopedA = Layer.succeed(ScopedA, ScopedA.of({}))
|
||||
const scopedB = Layer.effect(ScopedB, Effect.as(ScopedA, ScopedB.of({})))
|
||||
const globalScopedA = makeGlobalNode({ service: ScopedA, layer: scopedA, deps: [] })
|
||||
const locationScopedA = makeLocationNode({ service: ScopedA, layer: scopedA, deps: [] })
|
||||
|
||||
makeGlobalNode({ service: ScopedB, layer: scopedB, deps: [globalScopedA] })
|
||||
makeLocationNode({ service: ScopedB, layer: scopedB, deps: [globalScopedA] })
|
||||
makeLocationNode({ service: ScopedB, layer: scopedB, deps: [locationScopedA] })
|
||||
|
||||
// @ts-expect-error Global nodes cannot depend on location nodes
|
||||
makeGlobalNode({ service: ScopedB, layer: scopedB, deps: [locationScopedA] })
|
||||
|
||||
// @ts-expect-error ScopedB requires ScopedA
|
||||
makeLocationNode({ service: ScopedB, layer: scopedB, deps: [] })
|
||||
|
||||
test("type exploration compiles", () => {})
|
||||
222
packages/core/test/effect/layer-node/layer-node.test.ts
Normal file
222
packages/core/test/effect/layer-node/layer-node.test.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
|
||||
class Value extends Context.Service<Value, { readonly value: string }>()("test/LayerNodeValue") {}
|
||||
class Greeting extends Context.Service<Greeting, { readonly value: string }>()("test/LayerNodeGreeting") {}
|
||||
class Left extends Context.Service<Left, { readonly value: string }>()("test/LayerNodeLeft") {}
|
||||
class Right extends Context.Service<Right, { readonly value: string }>()("test/LayerNodeRight") {}
|
||||
class Database extends Context.Service<Database, { readonly name: string }>()("test/GraphDatabase") {}
|
||||
class Users extends Context.Service<Users, { readonly list: Effect.Effect<string[]> }>()("test/GraphUsers") {}
|
||||
class App extends Context.Service<App, { readonly run: Effect.Effect<string[]> }>()("test/GraphApp") {}
|
||||
|
||||
const tags = LayerNode.tags({ app: [] })
|
||||
const make = tags.make("app")
|
||||
const build = <A, E>(root: LayerNode.Node<A, E, any>, replacements?: readonly LayerNode.Replacement[]) =>
|
||||
LayerNode.compile(root, new Map(replacements?.map((item) => [item.source, item.replacement]))) as Layer.Layer<A, E>
|
||||
const valueLayer = Layer.succeed(Value, Value.of({ value: "production" }))
|
||||
const greetingLayer = Layer.effect(
|
||||
Greeting,
|
||||
Effect.map(Value, (value) => Greeting.of({ value: `hello ${value.value}` })),
|
||||
)
|
||||
const value = make({ service: Value, layer: valueLayer, deps: [] })
|
||||
const greeting = make({ service: Greeting, layer: greetingLayer, deps: [value] })
|
||||
|
||||
describe("layer node", () => {
|
||||
test("builds an untagged graph", async () => {
|
||||
const value = LayerNode.make({ service: Value, layer: valueLayer, deps: [] })
|
||||
const greeting = LayerNode.make({ service: Greeting, layer: greetingLayer, deps: [value] })
|
||||
const program = Effect.map(Greeting, (item) => item.value).pipe(
|
||||
Effect.provide(LayerNode.compile(LayerNode.group([greeting]))),
|
||||
)
|
||||
expect(await Effect.runPromise(program)).toBe("hello production")
|
||||
})
|
||||
|
||||
test("builds a dependency graph", async () => {
|
||||
const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(build(LayerNode.group([greeting]))))
|
||||
expect(await Effect.runPromise(program)).toBe("hello production")
|
||||
})
|
||||
|
||||
test("exposes roots but hides transitive dependencies", () => {
|
||||
const layer = build(LayerNode.group([greeting]))
|
||||
const check: Layer.Layer<Greeting> = layer
|
||||
void check
|
||||
})
|
||||
|
||||
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: [] })
|
||||
const leftLayer = Layer.effect(
|
||||
Left,
|
||||
Effect.map(Value, (item) => Left.of({ value: item.value })),
|
||||
)
|
||||
const rightLayer = Layer.effect(
|
||||
Right,
|
||||
Effect.map(Value, (item) => Right.of({ value: item.value })),
|
||||
)
|
||||
const left = make({ service: Left, layer: leftLayer, deps: [firstValue] })
|
||||
const right = make({ service: Right, layer: rightLayer, deps: [secondValue] })
|
||||
const layer = build(LayerNode.group([left, right]))
|
||||
const program = Effect.gen(function* () {
|
||||
return [(yield* Left).value, (yield* Right).value]
|
||||
}).pipe(Effect.provide(layer))
|
||||
expect(await Effect.runPromise(program)).toEqual(["first", "second"])
|
||||
})
|
||||
|
||||
test("requires unbound nodes to be bound before compilation", async () => {
|
||||
const unbound = LayerNode.unbound(Value, tags.values.app)
|
||||
const greeting = make({ service: Greeting, layer: greetingLayer, deps: [unbound] })
|
||||
const tree = LayerNode.group([greeting])
|
||||
expect(() => LayerNode.compile(tree)).toThrow("Unbound layer node: test/LayerNodeValue")
|
||||
const bound = LayerNode.bind(tree, unbound, value)
|
||||
const layer = LayerNode.compile(bound) as Layer.Layer<Greeting>
|
||||
const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(layer))
|
||||
expect(await Effect.runPromise(program)).toBe("hello production")
|
||||
})
|
||||
|
||||
test("replaces a layer by identity", async () => {
|
||||
const replacement = Layer.succeed(Value, Value.of({ value: "simulation" }))
|
||||
const program = Effect.map(Greeting, (item) => item.value).pipe(
|
||||
Effect.provide(build(LayerNode.group([greeting]), [LayerNode.replace(valueLayer, replacement)])),
|
||||
)
|
||||
expect(await Effect.runPromise(program)).toBe("hello simulation")
|
||||
})
|
||||
|
||||
test("replaces every use of the same layer", async () => {
|
||||
const leftLayer = Layer.effect(
|
||||
Left,
|
||||
Effect.map(Value, (item) => Left.of({ value: item.value })),
|
||||
)
|
||||
const rightLayer = Layer.effect(
|
||||
Right,
|
||||
Effect.map(Value, (item) => Right.of({ value: item.value })),
|
||||
)
|
||||
const left = make({ service: Left, layer: leftLayer, deps: [value] })
|
||||
const right = make({ service: Right, layer: rightLayer, deps: [value] })
|
||||
const replacement = Layer.succeed(Value, Value.of({ value: "replaced" }))
|
||||
const layer = build(LayerNode.group([left, right]), [LayerNode.replace(valueLayer, replacement)])
|
||||
const program = Effect.gen(function* () {
|
||||
return [(yield* Left).value, (yield* Right).value]
|
||||
}).pipe(Effect.provide(layer))
|
||||
expect(await Effect.runPromise(program)).toEqual(["replaced", "replaced"])
|
||||
})
|
||||
|
||||
test("does not acquire an unused replacement", async () => {
|
||||
let acquisitions = 0
|
||||
const other = Layer.succeed(Value, Value.of({ value: "other" }))
|
||||
const replacement = Layer.effect(
|
||||
Value,
|
||||
Effect.sync(() => {
|
||||
acquisitions++
|
||||
return Value.of({ value: "replacement" })
|
||||
}),
|
||||
)
|
||||
await Effect.runPromise(
|
||||
Effect.map(Greeting, (item) => item.value).pipe(
|
||||
Effect.provide(build(LayerNode.group([greeting]), [LayerNode.replace(other, replacement)])),
|
||||
),
|
||||
)
|
||||
expect(acquisitions).toBe(0)
|
||||
})
|
||||
|
||||
test("hoists and compiles tagged graphs", async () => {
|
||||
const tags = LayerNode.tags({ location: ["global"], global: [] })
|
||||
const global = tags.make("global")
|
||||
const location = tags.make("location")
|
||||
const database = global({
|
||||
service: Database,
|
||||
layer: Layer.succeed(Database, Database.of({ name: "Alice" })),
|
||||
deps: [],
|
||||
})
|
||||
const users = location({
|
||||
service: Users,
|
||||
layer: Layer.effect(
|
||||
Users,
|
||||
Effect.gen(function* () {
|
||||
const db = yield* Database
|
||||
return Users.of({ list: Effect.succeed([db.name]) })
|
||||
}),
|
||||
),
|
||||
deps: [database],
|
||||
})
|
||||
const app = location({
|
||||
service: App,
|
||||
layer: Layer.effect(
|
||||
App,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Users
|
||||
return App.of({ run: service.list })
|
||||
}),
|
||||
),
|
||||
deps: [users],
|
||||
})
|
||||
|
||||
const result = LayerNode.hoist(LayerNode.group([app]), tags.values.global)
|
||||
expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({
|
||||
kind: "group",
|
||||
dependencies: [],
|
||||
})
|
||||
expect(result.hoisted.dependencies).toEqual([database])
|
||||
|
||||
const layer = LayerNode.compile(result.node).pipe(
|
||||
Layer.provide(LayerNode.compile(result.hoisted)),
|
||||
) as unknown as Layer.Layer<App>
|
||||
const program = Effect.gen(function* () {
|
||||
return yield* (yield* App).run
|
||||
}).pipe(Effect.provide(layer))
|
||||
|
||||
expect(await Effect.runPromise(program)).toEqual(["Alice"])
|
||||
})
|
||||
|
||||
test("rejects conflicting hoisted implementations", () => {
|
||||
const tags = LayerNode.tags({ location: ["global"], global: [] })
|
||||
const global = tags.make("global")
|
||||
const location = tags.make("location")
|
||||
const first = global({
|
||||
service: Database,
|
||||
layer: Layer.succeed(Database, Database.of({ name: "first" })),
|
||||
deps: [],
|
||||
})
|
||||
const second = global({
|
||||
service: Database,
|
||||
layer: Layer.succeed(Database, Database.of({ name: "second" })),
|
||||
deps: [],
|
||||
})
|
||||
const left = location({
|
||||
service: Users,
|
||||
layer: Layer.effect(Users, Effect.as(Database, Users.of({ list: Effect.succeed([]) }))),
|
||||
deps: [first],
|
||||
})
|
||||
const right = location({
|
||||
service: App,
|
||||
layer: Layer.effect(App, Effect.as(Database, App.of({ run: Effect.succeed([]) }))),
|
||||
deps: [second],
|
||||
})
|
||||
|
||||
expect(() => LayerNode.hoist(LayerNode.group([left, right]), tags.values.global)).toThrow(
|
||||
"Tag global has conflicting implementations for test/GraphDatabase",
|
||||
)
|
||||
})
|
||||
|
||||
test("treats dependency groups as transparent while hoisting", () => {
|
||||
const tags = LayerNode.tags({ location: ["global"], global: [] })
|
||||
const global = tags.make("global")
|
||||
const location = tags.make("location")
|
||||
const database = global({
|
||||
service: Database,
|
||||
layer: Layer.succeed(Database, Database.of({ name: "Alice" })),
|
||||
deps: [],
|
||||
})
|
||||
const users = location({
|
||||
service: Users,
|
||||
layer: Layer.effect(Users, Effect.as(Database, Users.of({ list: Effect.succeed([]) }))),
|
||||
deps: [LayerNode.group([database])],
|
||||
})
|
||||
const result = LayerNode.hoist(LayerNode.group([users]), tags.values.global)
|
||||
|
||||
expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({
|
||||
kind: "group",
|
||||
dependencies: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
123
packages/core/test/effect/layer-node/node-build.test.ts
Normal file
123
packages/core/test/effect/layer-node/node-build.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { Context, Effect, Layer, LayerMap, Option } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Node } from "@opencode-ai/core/effect/app-node"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import type { LocationError, LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { tmpdir } from "../../fixture/tmpdir"
|
||||
|
||||
class Value extends Context.Service<Value, { readonly value: string }>()("test/TagValue") {}
|
||||
class Result extends Context.Service<Result, { readonly value: string }>()("test/TagResult") {}
|
||||
class CycleA extends Context.Service<CycleA, {}>()("test/NodeBuildA") {}
|
||||
class CycleB extends Context.Service<CycleB, { readonly directory: AbsolutePath }>()("test/NodeBuildB") {}
|
||||
|
||||
describe("node build", () => {
|
||||
test("does not build a location service map when the graph does not require it", async () => {
|
||||
const result = Node.makeGlobalNode({
|
||||
service: Result,
|
||||
layer: Layer.succeed(Result, Result.of({ value: "plain" })),
|
||||
deps: [],
|
||||
})
|
||||
const layer = AppNodeBuilder.build(result)
|
||||
const program = Effect.gen(function* () {
|
||||
expect(Option.isNone(yield* Effect.serviceOption(LocationServiceMap.Service))).toBe(true)
|
||||
return (yield* Result).value
|
||||
}).pipe(Effect.provide(layer))
|
||||
|
||||
expect(await Effect.runPromise(program)).toBe("plain")
|
||||
})
|
||||
|
||||
test("detects cycles through a bound location service map", () => {
|
||||
const a = Node.makeGlobalNode({
|
||||
service: CycleA,
|
||||
layer: Layer.effect(CycleA, Effect.as(LocationServiceMap.Service, CycleA.of({}))),
|
||||
deps: [LocationServiceMap.node],
|
||||
})
|
||||
const b = Node.makeGlobalNode({
|
||||
service: CycleB,
|
||||
layer: Layer.effect(
|
||||
CycleB,
|
||||
Effect.map(CycleA, () => CycleB.of({ directory: AbsolutePath.make(process.cwd()) })),
|
||||
),
|
||||
deps: [a],
|
||||
})
|
||||
const mapEffect = Effect.gen(function* () {
|
||||
const service = yield* CycleB
|
||||
return yield* LayerMap.make(
|
||||
(ref: Location.Ref) =>
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of({
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
project: { id: Project.ID.global, directory: service.directory },
|
||||
}),
|
||||
),
|
||||
{ idleTimeToLive: "1 minute" },
|
||||
)
|
||||
}) as unknown as Effect.Effect<LayerMap.LayerMap<Location.Ref, LocationServices, LocationError>, never, CycleB>
|
||||
const mapLayer = Layer.effect(LocationServiceMap.Service, mapEffect)
|
||||
const map = Node.makeGlobalNode({ service: LocationServiceMap.Service, layer: mapLayer, deps: [b] })
|
||||
const graph = LayerNode.bind(LayerNode.group([a]), LocationServiceMap.node, map)
|
||||
|
||||
expect(() => AppNodeBuilder.build(graph)).toThrow("Cycle detected in layer tree")
|
||||
})
|
||||
|
||||
test("shares top-level project with location services", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
let acquisitions = 0
|
||||
const projectLayer = Layer.effect(
|
||||
Project.Service,
|
||||
Effect.sync(() => {
|
||||
acquisitions++
|
||||
return Project.Service.of({
|
||||
directories: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
|
||||
commit: () => Effect.void,
|
||||
})
|
||||
}),
|
||||
)
|
||||
const layer = AppNodeBuilder.build(LayerNode.group([Project.node, LocationServiceMap.node]), [
|
||||
LayerNode.replace(Project.layer, projectLayer),
|
||||
])
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
|
||||
const program = Effect.gen(function* () {
|
||||
yield* Project.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
expect(Option.isSome(yield* Effect.serviceOption(LocationServiceMap.Service))).toBe(true)
|
||||
return yield* Location.Service.pipe(Effect.provide(locations.get(ref)))
|
||||
}).pipe(Effect.provide(layer))
|
||||
|
||||
expect((await Effect.runPromise(program)).directory).toBe(ref.directory)
|
||||
expect(acquisitions).toBe(1)
|
||||
})
|
||||
|
||||
test("returns a composed application layer", async () => {
|
||||
const value = Node.makeGlobalNode({
|
||||
service: Value,
|
||||
layer: Layer.succeed(Value, Value.of({ value: "value" })),
|
||||
deps: [],
|
||||
})
|
||||
const result = Node.makeGlobalNode({
|
||||
service: Result,
|
||||
layer: Layer.effect(
|
||||
Result,
|
||||
Effect.gen(function* () {
|
||||
return Result.of({ value: (yield* Value).value })
|
||||
}),
|
||||
),
|
||||
deps: [value],
|
||||
})
|
||||
const serviceLayer = AppNodeBuilder.build(result)
|
||||
const program = Effect.gen(function* () {
|
||||
expect(Option.isNone(yield* Effect.serviceOption(LocationServiceMap.Service))).toBe(true)
|
||||
return (yield* Result).value
|
||||
}).pipe(Effect.provide(serviceLayer))
|
||||
|
||||
expect(await Effect.runPromise(program)).toBe("value")
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, Stream } from "effect"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
|
|
@ -285,6 +285,69 @@ describe("EventV2", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("notifies global listeners only after a durable event is committed", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
const aggregateID = EventV2.ID.create()
|
||||
const observed = new Array<{ id: string; seq: number }>()
|
||||
yield* events.listen((event) =>
|
||||
event.type !== SyncMessage.type
|
||||
? Effect.void
|
||||
: db
|
||||
.select({ id: EventTable.id, seq: EventTable.seq })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.id, event.id))
|
||||
.get()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.tap((row) =>
|
||||
Effect.sync(() => {
|
||||
if (row) observed.push(row)
|
||||
}),
|
||||
),
|
||||
Effect.asVoid,
|
||||
),
|
||||
)
|
||||
|
||||
const event = yield* events.publish(SyncMessage, { id: aggregateID, text: "committed" })
|
||||
if (!event.durable) throw new Error("Expected durable event metadata")
|
||||
|
||||
expect(observed).toEqual([{ id: event.id, seq: event.durable.seq }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ends only an overflowing bounded subscriber without blocking other listeners", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const consuming = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const slowStream = yield* EventV2.allBounded(events, 1)
|
||||
const fastStream = yield* EventV2.allBounded(events, 8)
|
||||
const slow = yield* slowStream.pipe(
|
||||
Stream.runForEach(() => Deferred.succeed(consuming, undefined).pipe(Effect.andThen(Deferred.await(release)))),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
const fast = yield* fastStream.pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped)
|
||||
|
||||
yield* events.publish(Message, { text: "one" })
|
||||
yield* Deferred.await(consuming)
|
||||
yield* events.publish(Message, { text: "two" })
|
||||
yield* events.publish(Message, { text: "overflow" })
|
||||
const last = yield* events.publish(Message, { text: "still delivered" })
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
|
||||
const slowExit = yield* Fiber.await(slow)
|
||||
expect(Exit.findErrorOption(slowExit).pipe(Option.getOrUndefined)).toBeInstanceOf(EventV2.SubscriberOverflowError)
|
||||
expect(Array.from(yield* Fiber.join(fast))).toEqual([
|
||||
expect.objectContaining({ data: { text: "one" } }),
|
||||
expect.objectContaining({ data: { text: "two" } }),
|
||||
expect.objectContaining({ data: { text: "overflow" } }),
|
||||
last,
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves observer interruption", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
|
|
|
|||
|
|
@ -2,12 +2,13 @@ import { describe, expect } from "bun:test"
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Ripgrep.defaultLayer)
|
||||
const it = testEffect(LayerNode.compile(Ripgrep.node))
|
||||
|
||||
const withTmp = <A, E, R>(f: (directory: AbsolutePath) => Effect.Effect<A, E, R>) =>
|
||||
Effect.acquireRelease(
|
||||
|
|
|
|||
|
|
@ -3,13 +3,14 @@ import { $ } from "bun"
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { branch, commit, gitRemote } from "./fixture/git"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Git.defaultLayer)
|
||||
const it = testEffect(LayerNode.compile(Git.node))
|
||||
|
||||
describe("Git", () => {
|
||||
it.live("clones a remote and reads checkout metadata", () =>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
|
||||
import { Duration, Effect, Exit, Fiber, Scope, Stream } from "effect"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
Integration.locationLayer.pipe(Layer.provideMerge(Credential.defaultLayer), Layer.provideMerge(EventV2.defaultLayer)),
|
||||
)
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, EventV2.node])))
|
||||
|
||||
describe("Integration", () => {
|
||||
it.effect("registers integrations through the editor", () =>
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import fs from "fs/promises"
|
|||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Layer } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
|
|
@ -13,13 +13,18 @@ import { it } from "./lib/effect"
|
|||
|
||||
const provide = (directory: string) =>
|
||||
Effect.provide(
|
||||
FileSystem.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
FSUtil.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
),
|
||||
LayerNode.compile(
|
||||
LayerNode.bind(
|
||||
FileSystem.node,
|
||||
Location.node,
|
||||
makeLocationNode({
|
||||
service: Location.Service,
|
||||
layer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
),
|
||||
deps: [],
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { Tool } from "@opencode-ai/core/tool/tool"
|
|||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
|
@ -34,7 +34,7 @@ const applicationTools = ApplicationTools.layer
|
|||
const it = testEffect(
|
||||
Layer.merge(
|
||||
Layer.mergeAll(applicationTools, Database.defaultLayer, EventV2.defaultLayer),
|
||||
LocationServiceMap.layer.pipe(
|
||||
locationServiceMapLayer.pipe(
|
||||
Layer.provide(applicationTools),
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
|
|
@ -60,7 +60,7 @@ describe("LocationServiceMap", () => {
|
|||
Effect.flatMap((dir) =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const directory = AbsolutePath.make(dir.path)
|
||||
const constructed = Location.Ref.make({ directory })
|
||||
const decoded = Schema.decodeUnknownSync(Location.Ref)({ directory })
|
||||
|
|
@ -111,7 +111,9 @@ describe("LocationServiceMap", () => {
|
|||
}
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(LocationServiceMap.get(Location.Ref.make({ directory: AbsolutePath.make(directory) }))),
|
||||
Effect.provide(
|
||||
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(directory) })),
|
||||
),
|
||||
)
|
||||
|
||||
const blockedState = yield* update(blocked.path)
|
||||
|
|
@ -191,7 +193,7 @@ describe("LocationServiceMap", () => {
|
|||
location,
|
||||
}),
|
||||
),
|
||||
).pipe(Effect.provide(LocationServiceMap.get(location)), Effect.flip)
|
||||
).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
_tag: "SessionRunnerModel.ModelUnavailableError",
|
||||
|
|
@ -231,7 +233,7 @@ describe("LocationServiceMap", () => {
|
|||
})
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(LocationServiceMap.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))),
|
||||
Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import fs from "fs/promises"
|
|||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
|
|
@ -12,12 +13,18 @@ import { it } from "./lib/effect"
|
|||
|
||||
function provide(directory: string) {
|
||||
return Effect.provide(
|
||||
LocationMutation.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
FSUtil.defaultLayer,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
),
|
||||
LayerNode.compile(
|
||||
LayerNode.bind(
|
||||
LocationMutation.node,
|
||||
Location.node,
|
||||
makeLocationNode({
|
||||
service: Location.Service,
|
||||
layer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
),
|
||||
deps: [],
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
|
|||
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
|
|
@ -29,7 +29,7 @@ const project = Project.layer.pipe(
|
|||
Layer.provide(ProjectDirectories.defaultLayer),
|
||||
)
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(locationServiceMapLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(project),
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { Project } from "@opencode-ai/core/project"
|
|||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
|
|
@ -24,7 +24,7 @@ const current = Layer.succeed(
|
|||
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
|
||||
)
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(locationServiceMapLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
|
|
|
|||
|
|
@ -1,20 +1,12 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Effect } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { SkillPlugin } from "@opencode-ai/core/plugin/skill"
|
||||
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { host } from "./host"
|
||||
|
||||
const it = testEffect(
|
||||
SkillV2.layer.pipe(
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(SkillDiscovery.defaultLayer),
|
||||
Layer.provideMerge(AgentV2.locationLayer),
|
||||
),
|
||||
)
|
||||
const it = testEffect(AppNodeBuilder.build(SkillV2.node))
|
||||
|
||||
describe("SkillPlugin.Plugin", () => {
|
||||
it.effect("registers the built-in customize-opencode skill", () =>
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ import { tmpdir } from "node:os"
|
|||
import path from "node:path"
|
||||
import { Effect, Exit, Fiber, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(AppProcess.defaultLayer)
|
||||
const it = testEffect(LayerNode.compile(AppProcess.node))
|
||||
|
||||
const NODE = process.execPath
|
||||
const cmd = (...args: string[]) => ChildProcess.make(NODE, args)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, ProjectDirectories.defaultLayer))
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, ProjectDirectories.node])))
|
||||
|
||||
const projectID = Project.ID.make("project-directories")
|
||||
const directory = AbsolutePath.make("/tmp/project-directories")
|
||||
|
|
|
|||
|
|
@ -2,18 +2,15 @@ import { describe, expect } from "bun:test"
|
|||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(ProjectV2.defaultLayer, Database.defaultLayer, ProjectDirectories.defaultLayer))
|
||||
const it = testEffect(AppNodeBuilder.build(ProjectV2.node))
|
||||
|
||||
function remoteID(remote: string) {
|
||||
return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`))
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Context, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { QuestionV2 } from "@opencode-ai/core/question"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const questions = QuestionV2.layer.pipe(Layer.provide(EventV2.defaultLayer))
|
||||
const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, questions))
|
||||
const questions = AppNodeBuilder.build(LayerNode.group([EventV2.node, QuestionV2.node]))
|
||||
const it = testEffect(questions)
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_question_test")
|
||||
const question: QuestionV2.Info = {
|
||||
|
|
|
|||
|
|
@ -2,12 +2,13 @@ import { describe, expect } from "bun:test"
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { RelativePath } from "@opencode-ai/core/schema"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Ripgrep.defaultLayer)
|
||||
const it = testEffect(LayerNode.compile(Ripgrep.node))
|
||||
|
||||
describe("Ripgrep", () => {
|
||||
it.live("keeps ignored files out of catch-all find results", () =>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
|
|||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
|
|
@ -35,7 +35,7 @@ const projects = Layer.succeed(
|
|||
}),
|
||||
)
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(locationServiceMapLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
|
|
|
|||
174
packages/core/test/session-history.test.ts
Normal file
174
packages/core/test/session-history.test.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const projects = Layer.succeed(
|
||||
ProjectV2.Service,
|
||||
ProjectV2.Service.of({
|
||||
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(locationServiceMapLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(projects),
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
)
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
Database.defaultLayer,
|
||||
EventV2.defaultLayer,
|
||||
projects,
|
||||
SessionProjector.defaultLayer,
|
||||
SessionStore.defaultLayer,
|
||||
SessionExecution.noopLayer,
|
||||
sessions,
|
||||
),
|
||||
)
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
|
||||
const GapEvent = EventV2.define({
|
||||
type: "test.session.history.gap",
|
||||
durable: { aggregate: "sessionID", version: 1 },
|
||||
schema: { sessionID: SessionV2.ID, value: Schema.String },
|
||||
})
|
||||
|
||||
describe("SessionV2.history", () => {
|
||||
it.effect("returns an exhausted page for a migrated Session with no event sequence", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const session = yield* SessionV2.Service
|
||||
const sessionID = SessionV2.ID.make("ses_empty_history")
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: ProjectV2.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: ProjectV2.ID.global,
|
||||
slug: "empty-history",
|
||||
directory: "/project",
|
||||
title: "Empty history",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
|
||||
const first = yield* session.history({ sessionID, limit: 10 })
|
||||
|
||||
expect(first).toEqual({ events: [], hasMore: false })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats after as an exclusive aggregate sequence", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "one" })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "two" })
|
||||
|
||||
const page = yield* session.history({ sessionID: created.id, after: 1, limit: 10 })
|
||||
|
||||
expect(page.events.map((event) => event.durable?.seq)).toEqual([2])
|
||||
expect(page.hasMore).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("paginates public events in aggregate order across filtered gaps without duplicates", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "one" })
|
||||
yield* events.publish(GapEvent, { sessionID: created.id, value: "filtered" })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "two" })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "three" })
|
||||
|
||||
const first = yield* session.history({ sessionID: created.id, limit: 2 })
|
||||
const after = first.events.at(-1)?.durable?.seq
|
||||
const second = yield* session.history({
|
||||
sessionID: created.id,
|
||||
after,
|
||||
limit: 2,
|
||||
})
|
||||
const sequence = [...first.events, ...second.events].map((event) => event.durable?.seq)
|
||||
|
||||
expect(first.hasMore).toBe(true)
|
||||
expect(second.hasMore).toBe(false)
|
||||
expect(sequence).toEqual([1, 3, 4])
|
||||
expect(new Set(sequence).size).toBe(sequence.length)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("includes events committed between pages", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "one" })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "two" })
|
||||
|
||||
const first = yield* session.history({ sessionID: created.id, limit: 1 })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "later" })
|
||||
const second = yield* session.history({
|
||||
sessionID: created.id,
|
||||
after: first.events.at(-1)?.durable?.seq,
|
||||
limit: 10,
|
||||
})
|
||||
|
||||
expect(first.hasMore).toBe(true)
|
||||
expect([...first.events, ...second.events].map((event) => event.durable?.seq)).toEqual([1, 2, 3])
|
||||
expect(second.hasMore).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports exhaustion for exact-limit and limit-plus-one pages", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "one" })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "two" })
|
||||
|
||||
const exact = yield* session.history({ sessionID: created.id, limit: 2 })
|
||||
const oneMore = yield* session.history({ sessionID: created.id, limit: 1 })
|
||||
const exhausted = yield* session.history({
|
||||
sessionID: created.id,
|
||||
after: oneMore.events.at(-1)?.durable?.seq,
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
expect(exact.events).toHaveLength(2)
|
||||
expect(exact.hasMore).toBe(false)
|
||||
expect(oneMore.events).toHaveLength(1)
|
||||
expect(oneMore.hasMore).toBe(true)
|
||||
expect(exhausted.events).toHaveLength(1)
|
||||
expect(exhausted.hasMore).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails with NotFoundError for a missing Session", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const error = yield* session.history({ sessionID: SessionV2.ID.make("ses_missing"), limit: 10 }).pipe(Effect.flip)
|
||||
|
||||
expect(error._tag).toBe("Session.NotFoundError")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
|
@ -10,7 +13,6 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
|
|||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
|
|
@ -18,12 +20,18 @@ import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater
|
|||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionProjector.defaultLayer))
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node])))
|
||||
const sessionsLayer = AppNodeBuilder.build(
|
||||
LayerNode.bind(
|
||||
SessionV2.node,
|
||||
SessionExecution.node,
|
||||
makeGlobalNode({ service: SessionExecution.Service, layer: SessionExecution.noopLayer, deps: [] }),
|
||||
),
|
||||
)
|
||||
const sessionID = SessionV2.ID.make("ses_projector_test")
|
||||
const created = DateTime.makeUnsafe(0)
|
||||
const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }
|
||||
|
|
@ -161,18 +169,7 @@ describe("SessionProjector", () => {
|
|||
expect(
|
||||
(yield* sessions.context(sessionID)).map((message) => (message.type === "user" ? message.text : message.type)),
|
||||
).toEqual(["first", "second"])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
SessionV2.layer.pipe(
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(Project.defaultLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
),
|
||||
),
|
||||
),
|
||||
}).pipe(Effect.provide(sessionsLayer)),
|
||||
)
|
||||
|
||||
it.effect("marks an inbox row promoted with the Prompted event sequence", () =>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { Project } from "@opencode-ai/core/project"
|
|||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
|
|
@ -42,7 +42,7 @@ const execution = Layer.succeed(
|
|||
}),
|
||||
)
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(locationServiceMapLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { Project } from "@opencode-ai/core/project"
|
|||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
|
|
@ -103,7 +103,7 @@ const execution = Layer.effect(
|
|||
}),
|
||||
).pipe(Layer.provide(runner))
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(locationServiceMapLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
|
|||
import { QuestionV2 } from "@opencode-ai/core/question"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { ContextSnapshotDecodeError } from "@opencode-ai/core/session/error"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
|
|
@ -262,7 +262,7 @@ const execution = Layer.effect(
|
|||
}),
|
||||
).pipe(Layer.provide(runner))
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(locationServiceMapLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { asc } from "drizzle-orm"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
|
|
@ -11,7 +13,7 @@ import { SessionTable, TodoTable } from "@opencode-ai/core/session/sql"
|
|||
import { SessionTodo } from "@opencode-ai/core/session/todo"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionTodo.defaultLayer))
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionTodo.node])))
|
||||
const sessionID = SessionV2.ID.make("ses_todo_test")
|
||||
|
||||
const setup = Effect.gen(function* () {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import path from "path"
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||
|
|
@ -22,11 +24,9 @@ const discovery = Layer.succeed(
|
|||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
SkillV2.layer.pipe(
|
||||
Layer.provide(discovery),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provideMerge(AgentV2.locationLayer),
|
||||
),
|
||||
AppNodeBuilder.build(LayerNode.group([SkillV2.node, AgentV2.node]), [
|
||||
LayerNode.replace(SkillDiscovery.layer, discovery),
|
||||
]),
|
||||
)
|
||||
|
||||
function write(directory: string, name: string, description: string) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
|
|
@ -25,12 +28,14 @@ const locationLayer = Layer.succeed(
|
|||
),
|
||||
),
|
||||
)
|
||||
const locationNode = makeLocationNode({ service: Location.Service, layer: locationLayer, deps: [] })
|
||||
const builtInsNode = LayerNode.bind(
|
||||
LayerNode.group([SystemContextBuiltIns.node, SystemContextRegistry.node]),
|
||||
Location.node,
|
||||
locationNode,
|
||||
)
|
||||
const it = testEffect(
|
||||
SystemContextBuiltIns.locationLayer.pipe(
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Global.layerWith({ config: "/global" })),
|
||||
Layer.provide(locationLayer),
|
||||
),
|
||||
AppNodeBuilder.build(builtInsNode, [LayerNode.replace(Global.layer, Global.layerWith({ config: "/global" }))]),
|
||||
)
|
||||
const instructionFS = Layer.effect(
|
||||
FSUtil.Service,
|
||||
|
|
@ -43,13 +48,12 @@ const instructionFS = Layer.effect(
|
|||
}),
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provide(FSUtil.defaultLayer))
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
const itWithInstructions = testEffect(
|
||||
SystemContextBuiltIns.locationLayer.pipe(
|
||||
Layer.provide(instructionFS),
|
||||
Layer.provide(Global.layerWith({ config: "/global" })),
|
||||
Layer.provide(locationLayer),
|
||||
),
|
||||
AppNodeBuilder.build(builtInsNode, [
|
||||
LayerNode.replace(FSUtil.layer, instructionFS),
|
||||
LayerNode.replace(Global.layer, Global.layerWith({ config: "/global" })),
|
||||
]),
|
||||
)
|
||||
|
||||
describe("SystemContextBuiltIns", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue