mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-08 09:56:24 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77d8d33971 | ||
|
|
83dbeee32b | ||
|
|
a7e02e376e | ||
|
|
93588460df |
@@ -177,7 +177,7 @@ const table = sqliteTable("session", {
|
||||
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
|
||||
- Keep durable prompt admission separate from model execution. `Session.prompt(...)` publishes `session.inbox.enqueued`, whose projection inserts one durable `session_inbox` row, before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. Delivery publishes `session.inbox.delivered`; its projection consumes the inbox row and inserts the visible message in the same transaction. `session_inbox` stores only unconsumed work.
|
||||
- Reusing a Session ID adopts the existing Session. Reusing a user or synthetic inbox item ID is idempotent when Session and type match: the first admission wins and the retried payload, metadata, and delivery mode are ignored, whether the item is still pending or already delivered (reconciled from the projected message without retained enqueue history). Cross-Session or cross-type reuse fails. Control items keep their operation-specific conflict behavior.
|
||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
|
||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers its instance through `SessionStore` plus `InstanceMap.forSession(session)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Database } from "../src/database/database"
|
||||
import { Bus } from "../src/bus"
|
||||
import { SdkPlugins } from "../src/plugin/sdk"
|
||||
import { Location } from "../src/location"
|
||||
import { LocationServiceMap } from "../src/location-service-map"
|
||||
import { InstanceMap } from "../src/instance-map"
|
||||
import { AbsolutePath } from "../src/schema"
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
@@ -22,7 +22,7 @@ if (!Number.isInteger(iterations) || iterations < 1) {
|
||||
}
|
||||
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(path.resolve(directory)) })
|
||||
const layer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]))
|
||||
const layer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, InstanceMap.node]))
|
||||
|
||||
const measure = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -51,7 +51,7 @@ const print = (name: string, samples: ReadonlyArray<number>) => {
|
||||
}
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const load = locations.contextEffect(ref).pipe(Effect.scoped)
|
||||
|
||||
const first = yield* measure(load)
|
||||
|
||||
+31
-24
@@ -6,6 +6,7 @@ import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||
import { and, asc, eq, gt, lte, sql } from "drizzle-orm"
|
||||
import { Database } from "./database/database.js"
|
||||
import { EventSequenceTable, EventTable } from "./event/sql.js"
|
||||
import type { Instance } from "./instance.js"
|
||||
import type { Location } from "@opencode-ai/schema/location"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -201,23 +202,23 @@ export function configured(options?: Options) {
|
||||
const { db } = yield* Database.Service
|
||||
const logReadPageSize = options?.logReadPageSize ?? 512
|
||||
const persist = options?.persist ?? false
|
||||
const sessions = new Map<SessionID, Location.Ref>()
|
||||
const sessions = new Map<SessionID, Instance.Key>()
|
||||
// Keep routing separate from the public event, and retain its snapshot
|
||||
// while a slow subscriber drains events queued before a move or deletion.
|
||||
const routes = new WeakMap<Event.Payload, readonly Location.Ref[]>()
|
||||
const routes = new WeakMap<Event.Payload, readonly Instance.Key[]>()
|
||||
|
||||
const isSessionEvent = (event: Event.Payload): event is SessionEvent.Event =>
|
||||
Object.hasOwn(SessionEvent.All.cases, event.type)
|
||||
|
||||
const prepareRoutes = Effect.fnUntraced(function* (events: readonly Event.Payload[]) {
|
||||
const updates = new Map<SessionID, Location.Ref | undefined>()
|
||||
const resolved = new Map<Event.Payload, readonly Location.Ref[]>()
|
||||
const updates = new Map<SessionID, Instance.Key | undefined>()
|
||||
const resolved = new Map<Event.Payload, readonly Instance.Key[]>()
|
||||
for (const event of events) {
|
||||
if (!isSessionEvent(event)) continue
|
||||
const id = event.data.sessionID
|
||||
if (event.type === "session.created") {
|
||||
updates.set(id, event.data.location)
|
||||
resolved.set(event, [event.location ?? event.data.location])
|
||||
updates.set(id, Location.instanceKey(event.data.location))
|
||||
resolved.set(event, [Location.instanceKey(event.location ?? event.data.location)])
|
||||
continue
|
||||
}
|
||||
if (event.location && event.type !== "session.forked" && event.type !== "session.moved") {
|
||||
@@ -225,38 +226,42 @@ export function configured(options?: Options) {
|
||||
continue
|
||||
}
|
||||
const owner = event.type === "session.forked" ? event.data.parentID : id
|
||||
let ref = updates.has(owner) ? updates.get(owner) : sessions.get(owner)
|
||||
if (!ref && !updates.has(owner)) {
|
||||
let key = updates.has(owner) ? updates.get(owner) : sessions.get(owner)
|
||||
if (!key && !updates.has(owner)) {
|
||||
const row = yield* db
|
||||
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, owner))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
ref = row
|
||||
? { directory: AbsolutePath.make(row.directory), workspaceID: row.workspaceID ?? undefined }
|
||||
key = row
|
||||
? Location.instanceKey({
|
||||
directory: AbsolutePath.make(row.directory),
|
||||
workspaceID: row.workspaceID ?? undefined,
|
||||
})
|
||||
: undefined
|
||||
updates.set(owner, ref)
|
||||
updates.set(owner, key)
|
||||
}
|
||||
if (event.type === "session.moved") {
|
||||
// Both owners need the transition, even if the producer supplied
|
||||
// an envelope location. Later events use only the destination.
|
||||
updates.set(id, event.data.location)
|
||||
resolved.set(event, ref ? [ref, event.data.location] : [event.data.location])
|
||||
const destination = Location.instanceKey(event.data.location)
|
||||
updates.set(id, destination)
|
||||
resolved.set(event, key ? [key, destination] : [destination])
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.forked") updates.set(id, ref)
|
||||
resolved.set(event, event.location ? [event.location] : ref ? [ref] : [])
|
||||
if (event.type === "session.forked") updates.set(id, key)
|
||||
resolved.set(event, event.location ? [Location.instanceKey(event.location)] : key ? [key] : [])
|
||||
if (event.type === "session.deleted") updates.set(id, undefined)
|
||||
}
|
||||
// Apply only after the projection transaction commits. A failed move
|
||||
// must not redirect events away from the Session's actual location.
|
||||
// must not redirect events away from the Session's actual instance.
|
||||
return () => {
|
||||
for (const [id, ref] of updates) {
|
||||
if (ref) sessions.set(id, ref)
|
||||
for (const [id, key] of updates) {
|
||||
if (key) sessions.set(id, key)
|
||||
else sessions.delete(id)
|
||||
}
|
||||
for (const [event, ref] of resolved) routes.set(event, ref)
|
||||
for (const [event, keys] of resolved) routes.set(event, keys)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -736,13 +741,15 @@ export function configured(options?: Options) {
|
||||
Option.match(location, {
|
||||
onNone: () => stream,
|
||||
onSome: (location) => {
|
||||
const matches = (ref: Location.Ref) =>
|
||||
ref.directory === location.directory && ref.workspaceID === location.workspaceID
|
||||
const self = Location.instanceKey(location)
|
||||
return stream.pipe(
|
||||
Stream.filter((event) => {
|
||||
const refs = routes.get(event)
|
||||
if (refs) return refs.some(matches)
|
||||
return !event.location || matches(event.location)
|
||||
const keys = routes.get(event)
|
||||
if (keys) return keys.includes(self)
|
||||
if (!event.location) return true
|
||||
const key = Location.instanceKey(event.location)
|
||||
routes.set(event, [key])
|
||||
return key === self
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { buildLocationServiceMap } from "../location-services.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { buildInstanceMap } from "../location-services.js"
|
||||
import { InstanceMap } from "../instance-map.js"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
|
||||
export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements: LayerNode.Replacements = []) {
|
||||
// Only build the location service map if it's actually needed
|
||||
if (!LayerNode.hasUnbound(root, LocationServiceMap.node) || hasReplacement(replacements, LocationServiceMap.node))
|
||||
// Only build the instance map if it's actually needed
|
||||
if (!LayerNode.hasUnbound(root, InstanceMap.node) || hasReplacement(replacements, InstanceMap.node))
|
||||
return LayerNode.compile(root, replacements)
|
||||
|
||||
const locationMap = buildLocationServiceMap(replacements)
|
||||
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
|
||||
return LayerNode.compile(root, replacements.concat([[LocationServiceMap.node, locationMapNode]]))
|
||||
const instanceMap = buildInstanceMap(replacements)
|
||||
const instanceMapNode = makeGlobalNode({ service: InstanceMap.Service, layer: instanceMap, deps: [] })
|
||||
return LayerNode.compile(root, replacements.concat([[InstanceMap.node, instanceMapNode]]))
|
||||
}
|
||||
|
||||
function hasReplacement(replacements: LayerNode.Replacements, node: LayerNode.Node<unknown, unknown, any>) {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export * as InstanceKey from "./instance-key.js"
|
||||
|
||||
import { Brand } from "effect"
|
||||
|
||||
/** Process-local instance identity, not a wire or storage contract. */
|
||||
export type Key = string & Brand.Brand<"Instance.Key">
|
||||
export const Key = Brand.nominal<Key>()
|
||||
@@ -0,0 +1,30 @@
|
||||
export * as InstanceMap from "./instance-map.js"
|
||||
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import type { Location } from "./location.js"
|
||||
import type { Instance } from "./instance.js"
|
||||
|
||||
export interface Interface {
|
||||
/** Placement lookup: services for an explicitly requested location. */
|
||||
readonly get: (ref: Location.Ref) => Layer.Layer<Instance.Services, Instance.Error>
|
||||
readonly contextEffect: (
|
||||
ref: Location.Ref,
|
||||
) => Effect.Effect<Context.Context<Instance.Services>, Instance.Error, Scope.Scope>
|
||||
readonly invalidate: (ref: Location.Ref) => Effect.Effect<void>
|
||||
/** Membership only, including pending and failed entries; does not acquire services. */
|
||||
readonly has: (ref: Location.Ref) => Effect.Effect<boolean>
|
||||
/** Assignment lookup: services for the instance the Session belongs to. */
|
||||
readonly forSession: (session: { readonly location: Location.Ref }) => Layer.Layer<Instance.Services, Instance.Error>
|
||||
/** Retained construction inputs for cached entries; does not acquire services. */
|
||||
readonly entries: Effect.Effect<ReadonlyArray<{ readonly key: Instance.Key; readonly location: Location.Ref }>>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceMap") {
|
||||
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)
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Effect, Equal, Hash, LayerMap, RcMap } from "effect"
|
||||
import type { Instance } from "../instance.js"
|
||||
import type { InstanceMap } from "../instance-map.js"
|
||||
import { Location } from "../location.js"
|
||||
|
||||
/** The first acquisition retains construction input; only the key determines sharing. */
|
||||
export class Entry implements Equal.Equal {
|
||||
constructor(
|
||||
readonly key: Instance.Key,
|
||||
readonly location: Location.Ref,
|
||||
) {}
|
||||
|
||||
[Hash.symbol]() {
|
||||
return Hash.hash(this.key)
|
||||
}
|
||||
|
||||
[Equal.symbol](other: unknown) {
|
||||
return other instanceof Entry && this.key === other.key
|
||||
}
|
||||
|
||||
static forLocation(ref: Location.Ref) {
|
||||
const location = Location.canonical(ref)
|
||||
return new Entry(Location.instanceKey(location), location)
|
||||
}
|
||||
}
|
||||
|
||||
export function fromMap(map: LayerMap.LayerMap<Entry, Instance.Services, Instance.Error>): InstanceMap.Interface {
|
||||
return {
|
||||
get: (ref) => map.get(Entry.forLocation(ref)),
|
||||
contextEffect: (ref) => map.contextEffect(Entry.forLocation(ref)),
|
||||
invalidate: (ref) => map.invalidate(Entry.forLocation(ref)),
|
||||
has: (ref) => RcMap.has(map.rcMap, Entry.forLocation(ref)),
|
||||
forSession: (session) => map.get(Entry.forLocation(session.location)),
|
||||
entries: RcMap.keys(map.rcMap).pipe(
|
||||
Effect.map((entries) => Array.from(entries, (entry) => ({ key: entry.key, location: entry.location }))),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,8 @@ import { Vcs } from "./vcs.js"
|
||||
|
||||
export * as Instance from "./instance.js"
|
||||
|
||||
export { Key } from "./instance-key.js"
|
||||
|
||||
const nodes = [
|
||||
Location.node,
|
||||
Environment.node,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
export * as LocationActivity from "./location-activity.js"
|
||||
|
||||
import { Clock, Context, Duration, Effect, Layer, RcMap, Schema } from "effect"
|
||||
import { Clock, Context, Duration, Effect, Layer, Schema } from "effect"
|
||||
import { Bus } from "./bus.js"
|
||||
import type { Instance } from "./instance.js"
|
||||
import { Location } from "./location.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { InstanceMap } from "./instance-map.js"
|
||||
import { SessionEvent } from "./session/event.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
|
||||
@@ -17,46 +18,39 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
|
||||
Effect.gen(function* () {
|
||||
const clock = yield* Clock.Clock
|
||||
const bus = yield* Bus.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const timeToLive = Duration.toMillis(options.timeToLive ?? "60 minutes")
|
||||
const entries = new Map<string, { readonly ref: Location.Ref; expiresAt: number }>()
|
||||
const key = (ref: Location.Ref) => `${ref.directory}\0${ref.workspaceID ?? ""}`
|
||||
const touch = (ref: Location.Ref) =>
|
||||
const entries = new Map<Instance.Key, number>()
|
||||
const touch = (key: Instance.Key) =>
|
||||
Effect.sync(() => {
|
||||
entries.set(key(ref), { ref, expiresAt: clock.currentTimeMillisUnsafe() + timeToLive })
|
||||
entries.set(key, clock.currentTimeMillisUnsafe() + timeToLive)
|
||||
})
|
||||
|
||||
const unsubscribe = yield* bus.listen((event) => {
|
||||
if (!isSessionEvent(event)) return Effect.void
|
||||
const location = event.location
|
||||
if (!location) return Effect.void
|
||||
return RcMap.has(locations.rcMap, location).pipe(
|
||||
Effect.flatMap((active) => (active ? touch(location) : Effect.void)),
|
||||
)
|
||||
const key = Location.instanceKey(location)
|
||||
return locations.has(location).pipe(Effect.flatMap((active) => (active ? touch(key) : Effect.void)))
|
||||
})
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
yield* Effect.gen(function* () {
|
||||
yield* Effect.sleep(options.sweepInterval ?? "1 minute")
|
||||
const refs = Array.from(yield* RcMap.keys(locations.rcMap))
|
||||
const cached = new Set(refs.map(key))
|
||||
yield* Effect.forEach(
|
||||
refs,
|
||||
(ref) => (entries.has(key(ref)) ? Effect.void : touch(ref)),
|
||||
{ discard: true },
|
||||
)
|
||||
const cached = new Map((yield* locations.entries).map((entry) => [entry.key, entry.location]))
|
||||
yield* Effect.forEach(cached.keys(), (key) => (entries.has(key) ? Effect.void : touch(key)), { discard: true })
|
||||
for (const id of entries.keys()) {
|
||||
if (!cached.has(id)) entries.delete(id)
|
||||
}
|
||||
const now = clock.currentTimeMillisUnsafe()
|
||||
const expired = Array.from(entries.values()).filter((entry) => entry.expiresAt <= now)
|
||||
const expired = Array.from(cached).filter(([key]) => (entries.get(key) ?? Infinity) <= now)
|
||||
yield* Effect.forEach(
|
||||
expired,
|
||||
(entry) => {
|
||||
entries.delete(key(entry.ref))
|
||||
([key, ref]) => {
|
||||
entries.delete(key)
|
||||
return Effect.logInfo("location services evicted", {
|
||||
directory: entry.ref.directory,
|
||||
workspaceID: entry.ref.workspaceID,
|
||||
}).pipe(Effect.andThen(locations.invalidate(entry.ref)))
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
}).pipe(Effect.andThen(locations.invalidate(ref)))
|
||||
},
|
||||
{ discard: true },
|
||||
)
|
||||
@@ -70,5 +64,5 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer(),
|
||||
deps: [Bus.node, LocationServiceMap.node],
|
||||
deps: [Bus.node, InstanceMap.node],
|
||||
})
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Context, Effect, Layer, LayerMap } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { Location } from "./location.js"
|
||||
import type { Instance } from "./instance.js"
|
||||
|
||||
export class Service extends Context.Service<
|
||||
Service,
|
||||
LayerMap.LayerMap<Location.Ref, Instance.Services, Instance.Error>
|
||||
>()("@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.js"
|
||||
@@ -1,44 +1,30 @@
|
||||
import { Duration, Effect, Layer, LayerMap } from "effect"
|
||||
import { existsSync } from "fs"
|
||||
import path from "path"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Instance } from "./instance.js"
|
||||
import { Location } from "./location.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import { InstanceMap } from "./instance-map.js"
|
||||
import { Entry, fromMap } from "./instance-map/internal.js"
|
||||
|
||||
export { LocationServiceMap } from "./location-service-map.js"
|
||||
export { InstanceMap } from "./instance-map.js"
|
||||
|
||||
export type LocationServices = Instance.Services
|
||||
export type LocationError = Instance.Error
|
||||
|
||||
export function buildLocationServiceMap(
|
||||
replacements: LayerNode.Replacements = [],
|
||||
): Layer.Layer<LocationServiceMap.Service> {
|
||||
// Structural Equal distinguishes optional-key shape and Windows separator style.
|
||||
// The RcMap caches the raw key before the build callback, so normalize both here.
|
||||
const canonical = (ref: Location.Ref) =>
|
||||
Location.Ref.make({
|
||||
directory: AbsolutePath.make(process.platform === "win32" ? path.normalize(ref.directory) : ref.directory),
|
||||
workspaceID: ref.workspaceID,
|
||||
})
|
||||
export function buildInstanceMap(replacements: LayerNode.Replacements = []): Layer.Layer<InstanceMap.Service> {
|
||||
return Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
InstanceMap.Service,
|
||||
Effect.map(
|
||||
LayerMap.make((ref: Location.Ref) => Instance.layer(ref, { replacements }), {
|
||||
LayerMap.make((entry: Entry) => Instance.layer(entry.location, { replacements }), {
|
||||
// Workspace-placed directories exist only inside the workspace, so a
|
||||
// local stat consults the wrong filesystem. Workspace liveness is
|
||||
// owned by placement; do not probe the sandbox here, which would
|
||||
// provision lazily-idle workspaces.
|
||||
idleTimeToLive: (ref) =>
|
||||
ref.workspaceID !== undefined || existsSync(ref.directory) ? Duration.infinity : Duration.zero,
|
||||
}),
|
||||
(inner) => ({
|
||||
...inner,
|
||||
get: (ref: Location.Ref) => inner.get(canonical(ref)),
|
||||
contextEffect: (ref: Location.Ref) => inner.contextEffect(canonical(ref)),
|
||||
invalidate: (ref: Location.Ref) => inner.invalidate(canonical(ref)),
|
||||
idleTimeToLive: (entry) =>
|
||||
entry.location.workspaceID !== undefined || existsSync(entry.location.directory)
|
||||
? Duration.infinity
|
||||
: Duration.zero,
|
||||
}),
|
||||
fromMap,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import path from "path"
|
||||
import { Info, Ref, response } from "@opencode-ai/schema/location"
|
||||
import { InstanceKey } from "./instance-key.js"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import { Project } from "./project.js"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { makeLocationNode, tags } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -8,6 +11,23 @@ export * as Location from "./location.js"
|
||||
|
||||
export { Info, Ref, response }
|
||||
|
||||
export function canonical(ref: Ref): Ref {
|
||||
return Ref.make({
|
||||
directory: AbsolutePath.make(process.platform === "win32" ? path.normalize(ref.directory) : ref.directory),
|
||||
...(ref.workspaceID === undefined ? {} : { workspaceID: ref.workspaceID }),
|
||||
})
|
||||
}
|
||||
|
||||
/** The default assignment shares one instance per canonical location. No inverse is required. */
|
||||
export function instanceKey(ref: Ref): InstanceKey.Key {
|
||||
return InstanceKey.Key(
|
||||
JSON.stringify([
|
||||
process.platform === "win32" ? path.normalize(ref.directory) : ref.directory,
|
||||
ref.workspaceID ?? null,
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
export interface Interface extends Info {
|
||||
readonly vcs?: Project.Vcs
|
||||
readonly vcsBackend?: string
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Agent } from "../agent.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Job } from "../job.js"
|
||||
import { Location } from "../location.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { InstanceMap } from "../instance-map.js"
|
||||
import { Mcp } from "../mcp/index.js"
|
||||
import { PersistentPty } from "../persistent-pty.js"
|
||||
import { Session } from "../session.js"
|
||||
@@ -111,7 +111,7 @@ export const providerLayerWithCell = (cell: Cell) =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const jobs = yield* Job.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const persistentPty = yield* PersistentPty.Service
|
||||
const runtime: Interface = {
|
||||
session: sessions,
|
||||
@@ -169,7 +169,7 @@ export const providerNodeWithCell = (cell: Cell) =>
|
||||
makeGlobalNode({
|
||||
name: "plugin-runtime-provider",
|
||||
layer: providerLayerWithCell(cell),
|
||||
deps: [node, Session.node, Job.node, LocationServiceMap.node, PersistentPty.node],
|
||||
deps: [node, Session.node, Job.node, InstanceMap.node, PersistentPty.node],
|
||||
})
|
||||
|
||||
export const providerNode = providerNodeWithCell(defaultCell)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as Session from "./session.js"
|
||||
export * from "./session/schema.js"
|
||||
|
||||
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
|
||||
import { Cause, Effect, Layer, Schema, Context, Stream, Scope } from "effect"
|
||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { Project } from "./project.js"
|
||||
@@ -30,7 +30,7 @@ import { SessionExecution } from "./session/execution.js"
|
||||
import { SessionModelTransport } from "./session/model-transport.js"
|
||||
import { ForkEmptyError, MessageDecodeError, NotFoundError } from "./session/error.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { InstanceMap } from "./instance-map.js"
|
||||
import { SessionEvent } from "./session/event.js"
|
||||
import { SessionInbox } from "./session/inbox.js"
|
||||
import { InstructionState } from "./session/instruction-state.js"
|
||||
@@ -342,7 +342,7 @@ const layer = Layer.effect(
|
||||
const global = yield* Global.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const jobs = yield* Job.Service
|
||||
const environments = yield* SessionEnvironment.Service
|
||||
@@ -350,13 +350,9 @@ const layer = Layer.effect(
|
||||
const activeShells = new Set<SessionSchema.ID>()
|
||||
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||
const closeTransport = Effect.fn("Session.closeTransport")(function* (session: SessionSchema.Info) {
|
||||
const location = Location.Ref.make({
|
||||
directory: session.location.directory,
|
||||
workspaceID: session.location.workspaceID,
|
||||
})
|
||||
if (!(yield* RcMap.has(locations.rcMap, location))) return
|
||||
if (!(yield* locations.has(session.location))) return
|
||||
yield* SessionModelTransport.Service.use((transport) => transport.close(session.id)).pipe(
|
||||
Effect.provide(locations.get(location)),
|
||||
Effect.provide(locations.forSession(session)),
|
||||
)
|
||||
})
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
@@ -658,7 +654,7 @@ const layer = Layer.effect(
|
||||
if (existing) return existing
|
||||
const item = yield* restore(
|
||||
preparePrompt(input, messageID).pipe(
|
||||
Effect.provide(locations.get(session.location)),
|
||||
Effect.provide(locations.forSession(session)),
|
||||
Effect.provideService(FSUtil.Service, fs),
|
||||
),
|
||||
)
|
||||
@@ -690,7 +686,7 @@ const layer = Layer.effect(
|
||||
),
|
||||
generate: Effect.fn("Session.generate")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
const generate = yield* SessionGenerate.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||
const generate = yield* SessionGenerate.Service.pipe(Effect.provide(locations.forSession(session)))
|
||||
return yield* generate.generate(input)
|
||||
}),
|
||||
command: Effect.fn("Session.command")(function* (input) {
|
||||
@@ -699,7 +695,7 @@ const layer = Layer.effect(
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
return yield* Command.Service
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
}).pipe(Effect.provide(locations.forSession(session)))
|
||||
const delivery = input.delivery ?? "steer"
|
||||
yield* commands.execute({
|
||||
name: input.command,
|
||||
@@ -733,7 +729,7 @@ const layer = Layer.effect(
|
||||
metadata: { sessionID: input.sessionID },
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
}).pipe(Effect.provide(locations.forSession(session)))
|
||||
yield* bus.publish(
|
||||
SessionEvent.Shell.Started,
|
||||
{
|
||||
@@ -756,7 +752,7 @@ const layer = Layer.effect(
|
||||
.pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(missingShellOutput())))
|
||||
: missingShellOutput()
|
||||
return { shell: terminal.info, output }
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
}).pipe(Effect.provide(locations.forSession(session)))
|
||||
yield* bus.publish(SessionEvent.Shell.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
shell: completed.shell,
|
||||
@@ -774,7 +770,7 @@ const layer = Layer.effect(
|
||||
}),
|
||||
skill: Effect.fn("Session.skill")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
const skills = yield* Skill.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||
const skills = yield* Skill.Service.pipe(Effect.provide(locations.forSession(session)))
|
||||
const skill = yield* skills.get(input.skill)
|
||||
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
|
||||
yield* bus.publish(
|
||||
@@ -969,7 +965,7 @@ const layer = Layer.effect(
|
||||
Effect.provideService(Database.Service, database),
|
||||
Effect.provideService(Bus.Service, bus),
|
||||
)
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
}).pipe(Effect.provide(locations.forSession(session)))
|
||||
}),
|
||||
clear: Effect.fn("Session.revert.clear")(function* (sessionID) {
|
||||
const session = yield* result.get(sessionID)
|
||||
@@ -978,7 +974,7 @@ const layer = Layer.effect(
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
return yield* SessionRevert.clear(session).pipe(Effect.provideService(Bus.Service, bus))
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
}).pipe(Effect.provide(locations.forSession(session)))
|
||||
yield* execution.wake(sessionID)
|
||||
return revert
|
||||
}),
|
||||
@@ -1222,7 +1218,7 @@ export const node = makeGlobalNode({
|
||||
Project.node,
|
||||
SessionExecution.node,
|
||||
SessionStore.node,
|
||||
LocationServiceMap.node,
|
||||
InstanceMap.node,
|
||||
SessionProjector.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Cause, Context, Effect, Exit, Layer } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Job } from "../job.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { InstanceMap } from "../instance-map.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { SessionEvent } from "./event.js"
|
||||
import { SessionRunCoordinator } from "./run-coordinator.js"
|
||||
@@ -51,7 +51,7 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const bus = yield* Bus.Service
|
||||
const jobs = yield* Job.Service
|
||||
const db = (yield* Database.Service).db
|
||||
@@ -88,7 +88,7 @@ export const layer = Layer.effect(
|
||||
const result = yield* SessionRunner.Service.use((runner) =>
|
||||
runner.drain({ sessionID, force, continuation, promotable }),
|
||||
).pipe(
|
||||
Effect.provide(locations.get(session.location)),
|
||||
Effect.provide(locations.forSession(session)),
|
||||
Effect.tapCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.void
|
||||
@@ -170,7 +170,7 @@ export const layer = Layer.effect(
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node, Job.node],
|
||||
deps: [SessionStore.node, InstanceMap.node, Bus.node, Database.node, Job.node],
|
||||
})
|
||||
|
||||
/** Low-level compatibility layer for callers that only need durable Session recording. */
|
||||
|
||||
@@ -71,7 +71,7 @@ const layer = Layer.effect(
|
||||
const readable = files.filter((file): file is { path: string; content: string } => file !== undefined)
|
||||
if (readable.length === 0) return
|
||||
// Publish directly rather than through Session.synthetic: a Location-scoped layer
|
||||
// cannot depend on Session (it routes through LocationServiceMap, forming a type
|
||||
// cannot depend on Session (it routes through InstanceMap, forming a type
|
||||
// cycle with this node). The durable publish commits the synthetic and its metadata
|
||||
// ledger atomically, so releasing the claim afterwards cannot readmit the paths.
|
||||
yield* bus.publish(SessionEvent.Synthetic, {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { InstanceMap } from "@opencode-ai/core/location-services"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
@@ -25,12 +25,12 @@ import { tempGlobalLayer } from "../fixture/global"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, InstanceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
]),
|
||||
)
|
||||
const staticIt = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, InstanceMap.node]), [
|
||||
[ConfigPluginSource.node, ConfigPluginSource.empty],
|
||||
[Global.node, tempGlobalLayer],
|
||||
]),
|
||||
@@ -480,7 +480,7 @@ function withLocation<A, E, R>(
|
||||
Effect.flatMap((tmp) =>
|
||||
effect.pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }))),
|
||||
Effect.provide(InstanceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }))),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -4,11 +4,13 @@ import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { Entry, fromMap } from "@opencode-ai/core/instance-map/internal"
|
||||
import { InstanceMap } from "@opencode-ai/core/instance-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"
|
||||
import { location } from "../../fixture/location"
|
||||
|
||||
class Value extends Context.Service<Value, { readonly value: string }>()("test/TagValue") {}
|
||||
class Result extends Context.Service<Result, { readonly value: string }>()("test/TagResult") {}
|
||||
@@ -24,7 +26,7 @@ describe("node build", () => {
|
||||
})
|
||||
const layer = AppNodeBuilder.build(result)
|
||||
const program = Effect.gen(function* () {
|
||||
expect(Option.isNone(yield* Effect.serviceOption(LocationServiceMap.Service))).toBe(true)
|
||||
expect(Option.isNone(yield* Effect.serviceOption(InstanceMap.Service))).toBe(true)
|
||||
return (yield* Result).value
|
||||
}).pipe(Effect.provide(layer))
|
||||
|
||||
@@ -34,8 +36,8 @@ describe("node build", () => {
|
||||
test("detects cycles through a replaced location service map", async () => {
|
||||
const a = Node.makeGlobalNode({
|
||||
service: CycleA,
|
||||
layer: Layer.effect(CycleA, Effect.as(LocationServiceMap.Service, CycleA.of({}))),
|
||||
deps: [LocationServiceMap.node],
|
||||
layer: Layer.effect(CycleA, Effect.as(InstanceMap.Service, CycleA.of({}))),
|
||||
deps: [InstanceMap.node],
|
||||
})
|
||||
const b = Node.makeGlobalNode({
|
||||
service: CycleB,
|
||||
@@ -46,25 +48,19 @@ describe("node build", () => {
|
||||
deps: [a],
|
||||
})
|
||||
const mapLayer = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
InstanceMap.Service,
|
||||
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, canonical: service.directory },
|
||||
}),
|
||||
),
|
||||
const keyed = yield* LayerMap.make(
|
||||
(entry: Entry) =>
|
||||
Layer.succeed(Location.Service, location(entry.location, { projectDirectory: service.directory })),
|
||||
{ idleTimeToLive: "1 minute" },
|
||||
)
|
||||
}) as unknown as Effect.Effect<LayerMap.LayerMap<Location.Ref, LocationServices, LocationError>, never, CycleB>,
|
||||
return fromMap(keyed as unknown as LayerMap.LayerMap<Entry, LocationServices, LocationError>)
|
||||
}),
|
||||
)
|
||||
const map = Node.makeGlobalNode({ service: LocationServiceMap.Service, layer: mapLayer, deps: [b] })
|
||||
expect(() => AppNodeBuilder.build(LayerNode.group([a]), [[LocationServiceMap.node, map]])).toThrow(
|
||||
const map = Node.makeGlobalNode({ service: InstanceMap.Service, layer: mapLayer, deps: [b] })
|
||||
expect(() => AppNodeBuilder.build(LayerNode.group([a]), [[InstanceMap.node, map]])).toThrow(
|
||||
"Cycle detected in layer tree",
|
||||
)
|
||||
})
|
||||
@@ -84,13 +80,13 @@ describe("node build", () => {
|
||||
}),
|
||||
)
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
|
||||
const layer = AppNodeBuilder.build(LayerNode.group([Project.node, LocationServiceMap.node]), [
|
||||
const layer = AppNodeBuilder.build(LayerNode.group([Project.node, InstanceMap.node]), [
|
||||
[Project.node, projectLayer],
|
||||
])
|
||||
const program = Effect.gen(function* () {
|
||||
yield* Project.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
expect(Option.isSome(yield* Effect.serviceOption(LocationServiceMap.Service))).toBe(true)
|
||||
const locations = yield* InstanceMap.Service
|
||||
expect(Option.isSome(yield* Effect.serviceOption(InstanceMap.Service))).toBe(true)
|
||||
return yield* Location.Service.pipe(Effect.provide(locations.get(ref)))
|
||||
}).pipe(Effect.provide(layer))
|
||||
|
||||
@@ -116,7 +112,7 @@ describe("node build", () => {
|
||||
})
|
||||
const serviceLayer = AppNodeBuilder.build(result)
|
||||
const program = Effect.gen(function* () {
|
||||
expect(Option.isNone(yield* Effect.serviceOption(LocationServiceMap.Service))).toBe(true)
|
||||
expect(Option.isNone(yield* Effect.serviceOption(InstanceMap.Service))).toBe(true)
|
||||
return (yield* Result).value
|
||||
}).pipe(Effect.provide(serviceLayer))
|
||||
|
||||
|
||||
@@ -1,9 +1,29 @@
|
||||
import type { Instance } from "@opencode-ai/core/instance"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { InstanceMap } from "@opencode-ai/core/instance-map"
|
||||
import { Entry, fromMap } from "@opencode-ai/core/instance-map/internal"
|
||||
import type { LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect, Layer, LayerMap } from "effect"
|
||||
import { tmpdir } from "./tmpdir"
|
||||
|
||||
/**
|
||||
* Builds isolated services per key using the real map and location policy.
|
||||
* Only provided services are widened; required dependencies remain typed.
|
||||
*/
|
||||
export function stubLocations<A, R>(services: Layer.Layer<A, Instance.Error, R>) {
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
const layer = services as unknown as Layer.Layer<LocationServices, Instance.Error, R>
|
||||
return Layer.effect(
|
||||
InstanceMap.Service,
|
||||
Effect.map(
|
||||
LayerMap.make((_: Entry) => Layer.fresh(layer)),
|
||||
fromMap,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
|
||||
const directory = input.projectDirectory ?? ref.directory
|
||||
return {
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import type { LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Effect, Layer, LayerMap } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { stubLocations } from "./location"
|
||||
|
||||
// Plain-prompt unit fixtures use virtual directories and need only the admission hook services.
|
||||
export const promptLocationLayer = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
() =>
|
||||
Layer.merge(
|
||||
LayerNode.compile(PluginHooks.node),
|
||||
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
|
||||
) as Layer.Layer<LocationServices>,
|
||||
),
|
||||
export const promptLocationLayer = stubLocations(
|
||||
Layer.merge(LayerNode.compile(PluginHooks.node), Layer.succeed(PluginSupervisor.Service, { flush: Effect.void })),
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Deferred, Effect, Fiber } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { InstanceMap } from "@opencode-ai/core/location-services"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -19,7 +19,7 @@ import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, InstanceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
]),
|
||||
)
|
||||
@@ -48,9 +48,7 @@ function withFormatter<A, E, R>(
|
||||
return yield* body(yield* Formatter.Service, directory)
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(
|
||||
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(directory) })),
|
||||
),
|
||||
Effect.provide(InstanceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(directory) }))),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import type { Instance } from "@opencode-ai/core/instance"
|
||||
import { InstanceKey } from "@opencode-ai/core/instance-key"
|
||||
import { Entry, fromMap } from "@opencode-ai/core/instance-map/internal"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { Context, Deferred, Duration, Effect, Equal, Exit, Fiber, Hash, Layer, LayerMap, RcMap, Scope } from "effect"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
const firstLocation = Location.Ref.make({ directory: AbsolutePath.make("/first") })
|
||||
const secondLocation = Location.Ref.make({ directory: AbsolutePath.make("/second") })
|
||||
|
||||
class Value extends Context.Service<Value, { readonly location: Location.Ref }>()("InstanceMapTest/Value") {}
|
||||
|
||||
describe("InstanceMap", () => {
|
||||
it.effect("shares concurrent acquisitions by key and retains the first attempted input", () =>
|
||||
Effect.gen(function* () {
|
||||
const first = new Entry(InstanceKey.Key("shared"), firstLocation)
|
||||
const later = new Entry(first.key, secondLocation)
|
||||
const other = new Entry(InstanceKey.Key("isolated"), firstLocation)
|
||||
expect(Equal.equals(first, later)).toBe(true)
|
||||
expect(Hash.hash(first)).toBe(Hash.hash(later))
|
||||
expect(Equal.equals(first, other)).toBe(false)
|
||||
|
||||
const attempted: Entry[] = []
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const map = yield* LayerMap.make((entry: Entry) =>
|
||||
Layer.effect(
|
||||
Value,
|
||||
Effect.gen(function* () {
|
||||
attempted.push(entry)
|
||||
if (entry.key === first.key) {
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}
|
||||
return { location: entry.location }
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const firstFiber = yield* map.contextEffect(first).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* Deferred.await(started)
|
||||
const laterFiber = yield* map.contextEffect(later).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
expect(firstFiber.pollUnsafe()).toBeUndefined()
|
||||
expect(laterFiber.pollUnsafe()).toBeUndefined()
|
||||
expect(yield* RcMap.has(map.rcMap, later)).toBe(true)
|
||||
|
||||
// A different key can finish while the shared key is still constructing.
|
||||
const isolated = yield* map.contextEffect(other)
|
||||
expect(attempted).toEqual([first, other])
|
||||
expect(Array.from(yield* RcMap.keys(map.rcMap))[0]).toBe(first)
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
const shared = yield* Fiber.join(firstFiber)
|
||||
expect(yield* Fiber.join(laterFiber)).toBe(shared)
|
||||
expect(Context.get(shared, Value).location).toBe(first.location)
|
||||
expect(Context.get(isolated, Value)).not.toBe(Context.get(shared, Value))
|
||||
expect(attempted).toEqual([first, other])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("releases idle entries and lets the next acquisition reseed construction", () =>
|
||||
Effect.gen(function* () {
|
||||
const first = new Entry(InstanceKey.Key("shared"), firstLocation)
|
||||
const later = new Entry(first.key, secondLocation)
|
||||
const acquired: Entry[] = []
|
||||
const released: Entry[] = []
|
||||
const map = yield* LayerMap.make(
|
||||
(entry: Entry) =>
|
||||
Layer.effect(
|
||||
Value,
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
acquired.push(entry)
|
||||
return { location: entry.location }
|
||||
}),
|
||||
() => Effect.sync(() => released.push(entry)),
|
||||
),
|
||||
),
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
)
|
||||
const scope = yield* Effect.scope
|
||||
const firstScope = yield* Scope.fork(scope, "sequential")
|
||||
const laterScope = yield* Scope.fork(scope, "sequential")
|
||||
const original = yield* map.contextEffect(first).pipe(Scope.provide(firstScope))
|
||||
expect(yield* map.contextEffect(later).pipe(Scope.provide(laterScope))).toBe(original)
|
||||
yield* Scope.close(firstScope, Exit.void)
|
||||
expect(released).toEqual([])
|
||||
expect(yield* RcMap.has(map.rcMap, later)).toBe(true)
|
||||
yield* Scope.close(laterScope, Exit.void)
|
||||
expect(released).toEqual([])
|
||||
|
||||
// Invalidate only after every consumer releases; active replacement is out of scope.
|
||||
yield* map.invalidate(later)
|
||||
expect(released).toEqual([first])
|
||||
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([])
|
||||
const reseeded = yield* map.contextEffect(later).pipe(Effect.scoped)
|
||||
expect(reseeded).not.toBe(original)
|
||||
expect(Context.get(reseeded, Value).location).toBe(later.location)
|
||||
expect(acquired).toEqual([first, later])
|
||||
expect(Array.from(yield* RcMap.keys(map.rcMap))[0]).toBe(later)
|
||||
yield* map.invalidate(first)
|
||||
expect(released).toEqual([first, later])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps the first seed even when an infinite-TTL construction fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const first = new Entry(InstanceKey.Key("failed"), firstLocation)
|
||||
const later = new Entry(first.key, secondLocation)
|
||||
const attempted: Entry[] = []
|
||||
const map = yield* LayerMap.make(
|
||||
(entry: Entry) =>
|
||||
Layer.effect(
|
||||
Value,
|
||||
Effect.sync(() => attempted.push(entry)).pipe(Effect.andThen(Effect.fail(entry.location))),
|
||||
),
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
)
|
||||
|
||||
expect(yield* map.contextEffect(first).pipe(Effect.scoped, Effect.flip)).toBe(first.location)
|
||||
expect(yield* map.contextEffect(later).pipe(Effect.scoped, Effect.flip)).toBe(first.location)
|
||||
expect(attempted).toEqual([first])
|
||||
expect(yield* RcMap.has(map.rcMap, later)).toBe(true)
|
||||
expect(Array.from(yield* RcMap.keys(map.rcMap))[0]).toBe(first)
|
||||
|
||||
yield* map.invalidate(later)
|
||||
expect(yield* RcMap.has(map.rcMap, first)).toBe(false)
|
||||
expect(yield* map.contextEffect(later).pipe(Effect.scoped, Effect.flip)).toBe(later.location)
|
||||
expect(attempted).toEqual([first, later])
|
||||
expect(Array.from(yield* RcMap.keys(map.rcMap))[0]).toBe(later)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("exposes canonical retained refs and inspects or invalidates without booting", () =>
|
||||
Effect.gen(function* () {
|
||||
const attempted: Entry[] = []
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const map = fromMap(
|
||||
yield* LayerMap.make(
|
||||
(entry: Entry) => {
|
||||
const layer = Layer.effect(
|
||||
Location.Service,
|
||||
Effect.gen(function* () {
|
||||
attempted.push(entry)
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
yield* Deferred.await(release)
|
||||
return location(entry.location)
|
||||
}),
|
||||
)
|
||||
// Fixture boundary widens only output; dependencies and errors stay typed.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
return layer as unknown as Layer.Layer<Instance.Services>
|
||||
},
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
),
|
||||
)
|
||||
const ref = Location.Ref.make({
|
||||
directory: AbsolutePath.make(process.platform === "win32" ? "C:/workspace\\repo" : "/workspace/repo"),
|
||||
workspaceID: undefined,
|
||||
})
|
||||
const canonical = Location.Ref.make({
|
||||
directory: AbsolutePath.make(process.platform === "win32" ? "C:\\workspace\\repo" : "/workspace/repo"),
|
||||
})
|
||||
const workspace = Location.Ref.make({
|
||||
...canonical,
|
||||
workspaceID: Workspace.ID.make("wrk_team:alpha%3A\\segment"),
|
||||
})
|
||||
expect(map).not.toHaveProperty("rcMap")
|
||||
expect(yield* map.has(ref)).toBe(false)
|
||||
expect(yield* map.entries).toEqual([])
|
||||
yield* map.invalidate(ref)
|
||||
expect(attempted).toEqual([])
|
||||
|
||||
const pending = yield* map.contextEffect(ref).pipe(Effect.scoped, Effect.forkChild({ startImmediately: true }))
|
||||
yield* Deferred.await(started)
|
||||
expect(yield* map.has(canonical)).toBe(true)
|
||||
expect(yield* map.entries).toEqual([{ key: Location.instanceKey(canonical), location: canonical }])
|
||||
expect(Object.keys(attempted[0]?.location ?? {})).toEqual(["directory"])
|
||||
expect(pending.pollUnsafe()).toBeUndefined()
|
||||
yield* map.invalidate(workspace)
|
||||
expect(yield* map.has(workspace)).toBe(false)
|
||||
expect(attempted).toHaveLength(1)
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
const context = yield* Fiber.join(pending)
|
||||
expect(yield* map.contextEffect(canonical).pipe(Effect.scoped)).toBe(context)
|
||||
expect(yield* Location.Service.pipe(Effect.provide(map.get(canonical)), Effect.scoped)).toBe(
|
||||
Context.get(context, Location.Service),
|
||||
)
|
||||
expect(yield* Location.Service.pipe(Effect.provide(map.forSession({ location: ref })), Effect.scoped)).toBe(
|
||||
Context.get(context, Location.Service),
|
||||
)
|
||||
expect(Context.get(context, Location.Service).directory).toBe(canonical.directory)
|
||||
expect(attempted).toHaveLength(1)
|
||||
|
||||
const workspaceContext = yield* map.contextEffect(workspace).pipe(Effect.scoped)
|
||||
expect(workspaceContext).not.toBe(context)
|
||||
expect(Context.get(workspaceContext, Location.Service).workspaceID).toBe(workspace.workspaceID)
|
||||
expect(yield* map.entries).toEqual([
|
||||
{ key: Location.instanceKey(canonical), location: canonical },
|
||||
{ key: Location.instanceKey(workspace), location: workspace },
|
||||
])
|
||||
yield* map.invalidate(ref)
|
||||
expect(yield* map.has(canonical)).toBe(false)
|
||||
expect(yield* map.entries).toEqual([{ key: Location.instanceKey(workspace), location: workspace }])
|
||||
expect(attempted).toHaveLength(2)
|
||||
yield* map.invalidate(workspace)
|
||||
expect(yield* map.entries).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -8,8 +8,9 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Instance } from "@opencode-ai/core/instance"
|
||||
import { Entry, fromMap } from "@opencode-ai/core/instance-map/internal"
|
||||
import { InstancePlugins } from "@opencode-ai/core/plugin/instance"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { InstanceMap } from "@opencode-ai/core/location-services"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
@@ -26,24 +27,29 @@ const agentPlugin = (pluginID: string, agentID: string) =>
|
||||
effect: (ctx) => ctx.agent.transform((agents) => agents.update(Agent.ID.make(agentID), () => {})),
|
||||
})
|
||||
|
||||
// A host-owned assignment in miniature: the map decides per ref which plugins
|
||||
// an instance is born with, the way an embedder will per Slack thread.
|
||||
// The retained location selects construction options without interpreting the key.
|
||||
const instances = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
(ref: Location.Ref) =>
|
||||
Instance.layer(ref, {
|
||||
plugins: path.basename(ref.directory) === "thread-a" ? [agentPlugin("thread-a-plugin", "thread-a-agent")] : [],
|
||||
replacements: [[Global.node, tempGlobalLayer]],
|
||||
}),
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
InstanceMap.Service,
|
||||
Effect.map(
|
||||
LayerMap.make(
|
||||
(entry: Entry) => {
|
||||
const ref = entry.location
|
||||
return Instance.layer(ref, {
|
||||
plugins:
|
||||
path.basename(ref.directory) === "thread-a" ? [agentPlugin("thread-a-plugin", "thread-a-agent")] : [],
|
||||
replacements: [[Global.node, tempGlobalLayer]],
|
||||
})
|
||||
},
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
),
|
||||
fromMap,
|
||||
),
|
||||
)
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, InstanceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
[LocationServiceMap.node, instances],
|
||||
[InstanceMap.node, instances],
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -55,7 +61,7 @@ describe("InstancePlugins", () => {
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(agentPlugin("global-plugin", "global-agent"))
|
||||
|
||||
|
||||
@@ -7,8 +7,9 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Instance } from "@opencode-ai/core/instance"
|
||||
import { Entry, fromMap } from "@opencode-ai/core/instance-map/internal"
|
||||
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { InstanceMap } from "@opencode-ai/core/location-services"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
@@ -35,25 +36,29 @@ const hostConfig: LayerNode.Replacements = [
|
||||
|
||||
// Same directory contents, two instances: one vanilla, one with discovery.
|
||||
const instances = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
(ref: Location.Ref) => {
|
||||
const name = path.basename(ref.directory)
|
||||
return Instance.layer(ref, {
|
||||
// "bare" exercises the vanilla defaults themselves: no caller Config.
|
||||
discovery: name !== "vanilla" && name !== "bare",
|
||||
// Caller replacements win over the vanilla defaults.
|
||||
replacements: [[Global.node, tempGlobalLayer], ...(name === "vanilla" ? hostConfig : [])],
|
||||
})
|
||||
},
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
InstanceMap.Service,
|
||||
Effect.map(
|
||||
LayerMap.make(
|
||||
(entry: Entry) => {
|
||||
const ref = entry.location
|
||||
const name = path.basename(ref.directory)
|
||||
return Instance.layer(ref, {
|
||||
// "bare" exercises the vanilla defaults themselves: no caller Config.
|
||||
discovery: name !== "vanilla" && name !== "bare",
|
||||
// Caller replacements win over the vanilla defaults.
|
||||
replacements: [[Global.node, tempGlobalLayer], ...(name === "vanilla" ? hostConfig : [])],
|
||||
})
|
||||
},
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
),
|
||||
fromMap,
|
||||
),
|
||||
)
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, InstanceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
[LocationServiceMap.node, instances],
|
||||
[InstanceMap.node, instances],
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -65,7 +70,7 @@ describe("Instance vanilla", () => {
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const plant = (name: string) =>
|
||||
Effect.promise(async () => {
|
||||
const directory = path.join(dir.path, name)
|
||||
@@ -127,7 +132,7 @@ describe("Instance vanilla", () => {
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const directory = path.join(dir.path, "vanilla")
|
||||
const marker = path.join(directory, "ambient-loaded.txt")
|
||||
// A plugin module whose import writes a sentinel: project-marker
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Config } from "@opencode-ai/schema/config"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import {
|
||||
Context,
|
||||
DateTime,
|
||||
Deferred,
|
||||
Duration,
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
Hash,
|
||||
Layer,
|
||||
LayerMap,
|
||||
RcMap,
|
||||
Schema,
|
||||
Stream,
|
||||
} from "effect"
|
||||
@@ -24,7 +24,8 @@ import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { LocationServiceMap, type LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { Entry, fromMap } from "@opencode-ai/core/instance-map/internal"
|
||||
import { InstanceMap, type LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { LocationActivity } from "@opencode-ai/core/location-activity"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
@@ -41,6 +42,7 @@ import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolDefinitions, waitForTool } from "./lib/tool"
|
||||
import { Database } from "../src/database/database"
|
||||
@@ -49,41 +51,37 @@ import { Reference } from "../src/reference"
|
||||
import { Tool } from "../src/tool"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
]),
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, InstanceMap.node]), [[Global.node, tempGlobalLayer]]),
|
||||
)
|
||||
const itWithSdk = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, InstanceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
]),
|
||||
)
|
||||
const activityLocations = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
(ref) =>
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of({
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
project: { id: Project.ID.global, directory: ref.directory, canonical: ref.directory },
|
||||
}),
|
||||
) as unknown as Layer.Layer<LocationServices>,
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
InstanceMap.Service,
|
||||
Effect.map(
|
||||
LayerMap.make(
|
||||
(entry: Entry) => {
|
||||
const ref = entry.location
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
return Layer.succeed(Location.Service, location(ref)) as unknown as Layer.Layer<LocationServices>
|
||||
},
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
),
|
||||
fromMap,
|
||||
),
|
||||
)
|
||||
const itWithActivity = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node, LocationActivity.node]), [
|
||||
[LocationServiceMap.node, activityLocations],
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, InstanceMap.node, LocationActivity.node]), [
|
||||
[InstanceMap.node, activityLocations],
|
||||
]),
|
||||
)
|
||||
|
||||
describe("LocationServiceMap", () => {
|
||||
describe("InstanceMap", () => {
|
||||
itWithActivity.effect("does not refresh lifetime from inferred Session routing", () =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const bus = yield* Bus.Service
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
const sessionID = Session.ID.make("ses_routing_activity")
|
||||
@@ -99,13 +97,13 @@ describe("LocationServiceMap", () => {
|
||||
const event = yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID })
|
||||
expect(event).not.toHaveProperty("location")
|
||||
yield* TestClock.adjust("2 minutes")
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([])
|
||||
expect(yield* locations.entries).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
itWithActivity.effect("refreshes lifetime from Session events only", () =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const bus = yield* Bus.Service
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
const sessionID = Session.ID.make("ses_location_activity")
|
||||
@@ -115,16 +113,16 @@ describe("LocationServiceMap", () => {
|
||||
yield* TestClock.adjust("59 minutes")
|
||||
yield* bus.publish(Catalog.Event.Updated, {}, { location: ref })
|
||||
yield* TestClock.adjust("2 minutes")
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([])
|
||||
expect(yield* locations.entries).toEqual([])
|
||||
|
||||
yield* read
|
||||
yield* bus.publish(SessionEvent.Execution.Started, { sessionID }, { location: ref })
|
||||
yield* TestClock.adjust("59 minutes")
|
||||
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID }, { location: ref })
|
||||
yield* TestClock.adjust("1 minute")
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([ref])
|
||||
expect((yield* locations.entries).map((entry) => entry.key)).toEqual([Location.instanceKey(ref)])
|
||||
yield* TestClock.adjust("59 minutes")
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([])
|
||||
expect(yield* locations.entries).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -135,7 +133,7 @@ describe("LocationServiceMap", () => {
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const directory = path.join(dir.path, "recreated")
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
|
||||
|
||||
@@ -157,7 +155,7 @@ describe("LocationServiceMap", () => {
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const directory = AbsolutePath.make(path.join(dir.path, "workspace-only"))
|
||||
const workspaceRef = Location.Ref.make({ directory, workspaceID: Workspace.ID.make("wrk_liveness") })
|
||||
const localRef = Location.Ref.make({ directory })
|
||||
@@ -168,12 +166,12 @@ describe("LocationServiceMap", () => {
|
||||
// evicted by a zero idle time-to-live.
|
||||
const location = yield* Location.Service.pipe(Effect.provide(locations.get(workspaceRef)), Effect.scoped)
|
||||
expect(location.directory).toBe(directory)
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([workspaceRef])
|
||||
expect((yield* locations.entries).map((entry) => entry.key)).toEqual([Location.instanceKey(workspaceRef)])
|
||||
|
||||
// A local ref with the same missing directory keeps the existing
|
||||
// behavior: dropped as soon as it goes idle so a retry can rebuild it.
|
||||
yield* Location.Service.pipe(Effect.provide(locations.get(localRef)), Effect.scoped, Effect.exit)
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([workspaceRef])
|
||||
expect((yield* locations.entries).map((entry) => entry.key)).toEqual([Location.instanceKey(workspaceRef)])
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -187,7 +185,7 @@ describe("LocationServiceMap", () => {
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const id = Agent.ID.make("persistent-sdk-agent")
|
||||
const plugin = EffectPlugin.define({
|
||||
id: "persistent-sdk-plugin",
|
||||
@@ -228,7 +226,7 @@ describe("LocationServiceMap", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
yield* Deferred.await(started)
|
||||
|
||||
@@ -276,7 +274,7 @@ describe("LocationServiceMap", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
yield* Deferred.await(firstStarted)
|
||||
|
||||
@@ -333,7 +331,7 @@ describe("LocationServiceMap", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
yield* Deferred.await(firstStarted)
|
||||
|
||||
@@ -373,7 +371,7 @@ describe("LocationServiceMap", () => {
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
Effect.provide(context),
|
||||
@@ -409,7 +407,7 @@ describe("LocationServiceMap", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context))
|
||||
expect(activations.count).toBe(1)
|
||||
@@ -430,7 +428,7 @@ describe("LocationServiceMap", () => {
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context))
|
||||
|
||||
@@ -485,7 +483,7 @@ describe("LocationServiceMap", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
yield* Deferred.await(started)
|
||||
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
@@ -521,9 +519,7 @@ describe("LocationServiceMap", () => {
|
||||
return yield* plugins.list()
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(
|
||||
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })),
|
||||
),
|
||||
Effect.provide(InstanceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))),
|
||||
)
|
||||
|
||||
expect(plugins.map((plugin) => plugin.id)).toEqual([Plugin.ID.make("opencode.agent")])
|
||||
@@ -585,9 +581,7 @@ describe("LocationServiceMap", () => {
|
||||
expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"])
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(
|
||||
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })),
|
||||
),
|
||||
Effect.provide(InstanceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))),
|
||||
)
|
||||
}),
|
||||
),
|
||||
@@ -602,7 +596,7 @@ describe("LocationServiceMap", () => {
|
||||
Effect.flatMap(([first, second]) =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const bus = yield* Bus.Service
|
||||
const firstRef = Location.Ref.make({ directory: AbsolutePath.make(first.path) })
|
||||
const secondRef = Location.Ref.make({ directory: AbsolutePath.make(second.path) })
|
||||
@@ -639,7 +633,7 @@ describe("LocationServiceMap", () => {
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const directory = AbsolutePath.make(dir.path)
|
||||
const constructed = Location.Ref.make({ directory })
|
||||
const decoded = Schema.decodeUnknownSync(Location.Ref)({ directory })
|
||||
@@ -663,7 +657,7 @@ describe("LocationServiceMap", () => {
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const directory = AbsolutePath.make(dir.path)
|
||||
const alternate = AbsolutePath.make(directory.replaceAll("\\", "/"))
|
||||
const absent = Location.Ref.make({ directory: alternate })
|
||||
@@ -676,13 +670,14 @@ describe("LocationServiceMap", () => {
|
||||
|
||||
const first = yield* locations.contextEffect(absent)
|
||||
expect(yield* locations.contextEffect(present)).toBe(first)
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([
|
||||
Location.Ref.make({ directory, workspaceID: undefined }),
|
||||
expect(yield* locations.entries).toEqual([
|
||||
{ key: Location.instanceKey(present), location: Location.canonical(present) },
|
||||
])
|
||||
expect(Context.get(first, Location.Service).directory).toBe(directory)
|
||||
|
||||
// Invalidating with the shape opposite to the one that booted must evict.
|
||||
yield* locations.invalidate(present)
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(0)
|
||||
expect(yield* locations.entries).toHaveLength(0)
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -726,9 +721,7 @@ describe("LocationServiceMap", () => {
|
||||
}
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(
|
||||
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(directory) })),
|
||||
),
|
||||
Effect.provide(InstanceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(directory) }))),
|
||||
)
|
||||
|
||||
const blockedID = Provider.ID.make("blocked-location")
|
||||
@@ -816,7 +809,7 @@ describe("LocationServiceMap", () => {
|
||||
}),
|
||||
catalog.model.available,
|
||||
)
|
||||
}).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip)
|
||||
}).pipe(Effect.provide(InstanceMap.Service.get(location)), Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
_tag: "SessionRunnerModel.ModelUnavailableError",
|
||||
@@ -859,7 +852,7 @@ describe("LocationServiceMap", () => {
|
||||
}),
|
||||
catalog.model.available,
|
||||
)
|
||||
}).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip)
|
||||
}).pipe(Effect.provide(InstanceMap.Service.get(location)), Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
_tag: "SessionRunnerModel.ModelUnavailableError",
|
||||
@@ -913,7 +906,7 @@ describe("LocationServiceMap", () => {
|
||||
}),
|
||||
catalog.model.available,
|
||||
)
|
||||
}).pipe(Effect.provide(LocationServiceMap.Service.get(location)))
|
||||
}).pipe(Effect.provide(InstanceMap.Service.get(location)))
|
||||
|
||||
expect(resolved.ref).toEqual(
|
||||
Model.Ref.make({
|
||||
@@ -956,7 +949,7 @@ describe("LocationServiceMap", () => {
|
||||
})
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))),
|
||||
Effect.provide(InstanceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1010,12 +1003,44 @@ describe("LocationServiceMap", () => {
|
||||
expect((yield* mcp.servers()).map((server) => String(server.name))).toEqual(["dynamic", "example"])
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(
|
||||
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })),
|
||||
),
|
||||
Effect.provide(InstanceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Location.instanceKey", () => {
|
||||
test("distinguishes directories and workspace placement", () => {
|
||||
const local = Location.Ref.make({ directory: AbsolutePath.make(path.resolve("instance-key")) })
|
||||
const workspace = Location.Ref.make({
|
||||
directory: local.directory,
|
||||
workspaceID: Workspace.ID.make("wrk_instance_key"),
|
||||
})
|
||||
const other = Location.Ref.make({ directory: AbsolutePath.make(path.resolve("other-repo")) })
|
||||
expect(new Set([local, workspace, other].map(Location.instanceKey)).size).toBe(3)
|
||||
})
|
||||
|
||||
test("optional-key shape does not split instances", () => {
|
||||
const explicit = Location.Ref.make({ directory: AbsolutePath.make("/tmp/instance-key"), workspaceID: undefined })
|
||||
const implicit = Location.Ref.make({ directory: AbsolutePath.make("/tmp/instance-key") })
|
||||
expect(Location.instanceKey(explicit)).toBe(Location.instanceKey(implicit))
|
||||
})
|
||||
|
||||
test("distinguishes workspace IDs containing separators and escapes", () => {
|
||||
const ref = Location.Ref.make({
|
||||
directory: AbsolutePath.make(path.resolve("repo")),
|
||||
workspaceID: Workspace.ID.make("wrk_team:alpha%3A"),
|
||||
})
|
||||
const escaped = Location.Ref.make({ ...ref, workspaceID: Workspace.ID.make("wrk_team%3Aalpha%3A") })
|
||||
expect(Location.instanceKey(ref)).not.toBe(Location.instanceKey(escaped))
|
||||
})
|
||||
|
||||
test.skipIf(process.platform !== "win32")("normalizes Windows separators before minting", () => {
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make("C:\\workspace\\repo") })
|
||||
const mixed = Location.Ref.make({ directory: AbsolutePath.make("C:/workspace\\repo") })
|
||||
expect(Location.instanceKey(mixed)).toBe(Location.instanceKey(ref))
|
||||
expect(Location.canonical(mixed)).toEqual(ref)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Ref } from "effect"
|
||||
import { InstanceMap } from "@opencode-ai/core/instance-map"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { stubLocations } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
class InitialCount extends Context.Service<InitialCount, number>()("test/LocationStubInitialCount") {}
|
||||
|
||||
const it = testEffect(Layer.succeed(InitialCount, 0))
|
||||
|
||||
it.effect("isolates mutable services across locations and reuses them within one location", () =>
|
||||
Effect.gen(function* () {
|
||||
const counts: number[] = []
|
||||
const services = Layer.effect(
|
||||
PluginSupervisor.Service,
|
||||
Effect.gen(function* () {
|
||||
const initial = yield* InitialCount
|
||||
const count = yield* Ref.make(initial)
|
||||
return {
|
||||
flush: Ref.updateAndGet(count, (value) => value + 1).pipe(
|
||||
Effect.tap((value) => Effect.sync(() => counts.push(value))),
|
||||
Effect.asVoid,
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* Effect.gen(function* () {
|
||||
const locations = yield* InstanceMap.Service
|
||||
const first = yield* locations.contextEffect({ directory: AbsolutePath.make(path.resolve("a")) })
|
||||
const second = yield* locations.contextEffect({ directory: AbsolutePath.make(path.resolve("b")) })
|
||||
expect(yield* locations.contextEffect({ directory: AbsolutePath.make(path.resolve("a")) })).toBe(first)
|
||||
|
||||
const firstSupervisor = Context.get(first, PluginSupervisor.Service)
|
||||
const secondSupervisor = Context.get(second, PluginSupervisor.Service)
|
||||
expect(
|
||||
yield* PluginSupervisor.Service.pipe(
|
||||
Effect.provide(locations.forSession({ location: { directory: AbsolutePath.make(path.resolve("a")) } })),
|
||||
),
|
||||
).toBe(firstSupervisor)
|
||||
yield* firstSupervisor.flush
|
||||
yield* firstSupervisor.flush
|
||||
yield* secondSupervisor.flush
|
||||
expect(counts).toEqual([1, 2, 1])
|
||||
}).pipe(Effect.provide(stubLocations(services)), Effect.scoped)
|
||||
}),
|
||||
)
|
||||
|
||||
test("stub types preserve requirements and reject undeclared errors", () => {
|
||||
const dependent = stubLocations(Layer.effectDiscard(InitialCount))
|
||||
const required: Layer.Layer<InstanceMap.Service, never, InitialCount> = dependent
|
||||
// @ts-expect-error Required dependencies cannot be erased by the stub.
|
||||
const closed: Layer.Layer<InstanceMap.Service> = dependent
|
||||
// @ts-expect-error Intentional fixture failures must use the map's error contract or defects.
|
||||
stubLocations(Layer.effectDiscard(Effect.fail(new Error("fixture failure"))))
|
||||
void required
|
||||
void closed
|
||||
})
|
||||
@@ -7,8 +7,8 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import type { LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { InstanceMap } from "@opencode-ai/core/instance-map"
|
||||
import { stubLocations } from "./fixture/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
@@ -19,7 +19,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Effect, Layer, LayerMap, Stream } from "effect"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectLayer } from "./lib/project"
|
||||
|
||||
@@ -48,24 +48,15 @@ const models = Layer.mock(SessionRunnerModel.Service)({
|
||||
}),
|
||||
),
|
||||
})
|
||||
const locations = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
() =>
|
||||
// The test only needs the compaction location service used by Session.compact.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
SessionCompaction.layer.pipe(
|
||||
Layer.provide(client),
|
||||
Layer.provide(config),
|
||||
Layer.provide(models),
|
||||
) as unknown as Layer.Layer<LocationServices>,
|
||||
),
|
||||
// The test only needs the compaction location service used by Session.compact.
|
||||
const locations = stubLocations(
|
||||
SessionCompaction.layer.pipe(Layer.provide(client), Layer.provide(config), Layer.provide(models)),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[LocationServiceMap.node, locations],
|
||||
[InstanceMap.node, locations],
|
||||
[Project.node, globalProjectLayer],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
|
||||
@@ -34,7 +34,7 @@ import { SessionTransfer } from "@opencode-ai/core/session/transfer"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { Expected } from "./lib/session-message"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { InstanceMap } from "@opencode-ai/core/instance-map"
|
||||
import { promptLocationLayer } from "./fixture/prompt-location"
|
||||
import { globalProjectLayer } from "./lib/project"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
@@ -53,7 +53,7 @@ const it = testEffect(
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[Project.node, globalProjectLayer],
|
||||
[LocationServiceMap.node, promptLocationLayer],
|
||||
[InstanceMap.node, promptLocationLayer],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -6,8 +6,8 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import type { LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { InstanceMap } from "@opencode-ai/core/instance-map"
|
||||
import { stubLocations } from "./fixture/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -21,7 +21,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
|
||||
import { SessionInboxTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Context, Deferred, Effect, Exit, Fiber, Layer, LayerMap, Scope } from "effect"
|
||||
import { Context, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -1295,15 +1295,8 @@ function buildExecution(
|
||||
drain: (input) => drain(input).pipe(Effect.as(SessionRunner.DrainResult.Complete())),
|
||||
}),
|
||||
)
|
||||
const locations = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
() =>
|
||||
// The local execution test only needs the Session runner from the Location graph.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
runner as unknown as Layer.Layer<LocationServices>,
|
||||
),
|
||||
)
|
||||
// The local execution test only needs the Session runner from the Location graph.
|
||||
const locations = stubLocations(runner)
|
||||
return yield* Layer.buildWithScope(
|
||||
SessionRestart.layer(options).pipe(
|
||||
Layer.provideMerge(sessionLayer),
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { mkdir, rm } from "fs/promises"
|
||||
import { Effect, Layer, LayerMap } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
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 { LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { InstanceMap } from "@opencode-ai/core/instance-map"
|
||||
import { stubLocations } from "./fixture/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
@@ -30,19 +30,14 @@ const it = testEffect(
|
||||
],
|
||||
),
|
||||
)
|
||||
const unavailableLocations = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
() => Layer.effectDiscard(Effect.fail(new Error("broken location"))) as unknown as Layer.Layer<LocationServices>,
|
||||
),
|
||||
)
|
||||
const unavailableLocations = stubLocations(Layer.effectDiscard(Effect.die(new Error("broken location"))))
|
||||
const itWithUnavailableDestination = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Project.node, globalProjectLayer],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
[LocationServiceMap.node, unavailableLocations],
|
||||
[InstanceMap.node, unavailableLocations],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { InstanceMap } from "@opencode-ai/core/instance-map"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
@@ -34,7 +34,7 @@ const it = testEffect(
|
||||
Bus.node,
|
||||
SessionProjector.node,
|
||||
Session.node,
|
||||
LocationServiceMap.node,
|
||||
InstanceMap.node,
|
||||
PluginRuntime.providerNodeWithCell(runtime),
|
||||
]),
|
||||
[
|
||||
@@ -56,7 +56,7 @@ const setup = Effect.gen(function* () {
|
||||
const tmp = yield* project
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ location: { directory: AbsolutePath.make(tmp.path) } })
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const services = locations.get(session.location)
|
||||
const hooks = yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Fiber, Layer, LayerMap, Schema, Stream } from "effect"
|
||||
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { mkdtemp, rm } from "fs/promises"
|
||||
import { tmpdir } from "os"
|
||||
import path from "path"
|
||||
@@ -24,8 +24,8 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { SessionInboxTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import type { LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { InstanceMap } from "@opencode-ai/core/instance-map"
|
||||
import { stubLocations } from "./fixture/location"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
@@ -58,36 +58,29 @@ const execution = Layer.succeed(
|
||||
awaitIdle: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const locations = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
() =>
|
||||
// These operations resolve Location services lazily and must wait for plugin-projected state.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
Layer.unwrap(
|
||||
Effect.sync(() => {
|
||||
let ready = false
|
||||
return Layer.mergeAll(
|
||||
LayerNode.compile(PluginHooks.node),
|
||||
Layer.mock(Image.Service, {
|
||||
normalize: (_resource, content) =>
|
||||
ready
|
||||
? Effect.succeed(content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content)
|
||||
: Effect.die(new Error("Image service used before plugins were ready")),
|
||||
}),
|
||||
Layer.mock(Snapshot.Service, {
|
||||
capture: () =>
|
||||
ready ? Effect.undefined : Effect.die(new Error("Snapshot used before plugins were ready")),
|
||||
restore: () =>
|
||||
ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready")),
|
||||
}),
|
||||
Layer.succeed(
|
||||
PluginSupervisor.Service,
|
||||
PluginSupervisor.Service.of({ flush: Effect.sync(() => (ready = true)) }),
|
||||
),
|
||||
)
|
||||
// These operations resolve Location services lazily and must wait for plugin-projected state.
|
||||
const locations = stubLocations(
|
||||
Layer.unwrap(
|
||||
Effect.sync(() => {
|
||||
let ready = false
|
||||
return Layer.mergeAll(
|
||||
LayerNode.compile(PluginHooks.node),
|
||||
Layer.mock(Image.Service, {
|
||||
normalize: (_resource, content) =>
|
||||
ready
|
||||
? Effect.succeed(content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content)
|
||||
: Effect.die(new Error("Image service used before plugins were ready")),
|
||||
}),
|
||||
) as unknown as Layer.Layer<LocationServices>,
|
||||
Layer.mock(Snapshot.Service, {
|
||||
capture: () => (ready ? Effect.undefined : Effect.die(new Error("Snapshot used before plugins were ready"))),
|
||||
restore: () => (ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready"))),
|
||||
}),
|
||||
Layer.succeed(
|
||||
PluginSupervisor.Service,
|
||||
PluginSupervisor.Service.of({ flush: Effect.sync(() => (ready = true)) }),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
const it = testEffect(
|
||||
@@ -96,7 +89,7 @@ const it = testEffect(
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[SessionExecution.node, execution],
|
||||
[LocationServiceMap.node, locations],
|
||||
[InstanceMap.node, locations],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ import { SessionModelTransport } from "@opencode-ai/core/session/model-transport
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionEnvironment } from "@opencode-ai/core/session/environment"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { InstanceMap } from "@opencode-ai/core/location-services"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectLayer } from "./lib/project"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
@@ -36,7 +36,7 @@ const it = testEffect(
|
||||
SessionStore.node,
|
||||
SessionEnvironment.node,
|
||||
Session.node,
|
||||
LocationServiceMap.node,
|
||||
InstanceMap.node,
|
||||
]),
|
||||
[
|
||||
[Project.node, globalProjectLayer],
|
||||
@@ -56,7 +56,7 @@ describe("Session.remove", () => {
|
||||
const child = yield* session.create({ parentID: parent.id })
|
||||
yield* session.environment({ sessionID: parent.id, variables: { SESSION_ENV: "parent" } })
|
||||
yield* session.environment({ sessionID: child.id, variables: { SESSION_ENV: "child" } })
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
yield* Effect.acquireRelease(locations.contextEffect(location), () => locations.invalidate(location))
|
||||
closed.length = 0
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { InstanceMap } from "@opencode-ai/core/instance-map"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
@@ -28,7 +28,7 @@ import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]),
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, InstanceMap.node]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[Global.node, tempGlobalLayer],
|
||||
@@ -110,7 +110,7 @@ describe("Session.revert files", () => {
|
||||
"Keep this later edit.\n",
|
||||
)
|
||||
expect((yield* session.get(created.id)).revert).toBeUndefined()
|
||||
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
|
||||
}).pipe(Effect.provide(InstanceMap.Service.get(created.location)))
|
||||
}),
|
||||
// Real Location/plugin startup and Git snapshots can exceed five seconds under CI load.
|
||||
{ timeout: 15_000 },
|
||||
|
||||
@@ -41,7 +41,7 @@ import { Effect, Layer } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import path from "node:path"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { InstanceMap } from "@opencode-ai/core/instance-map"
|
||||
import { promptLocationLayer } from "./fixture/prompt-location"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { agentHost, catalogHost, host } from "./plugin/host"
|
||||
@@ -155,7 +155,7 @@ const testLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
|
||||
]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[LocationServiceMap.node, promptLocationLayer],
|
||||
[InstanceMap.node, promptLocationLayer],
|
||||
[LayerNodePlatform.llmClient, llmClient],
|
||||
[Permission.node, permission],
|
||||
[Catalog.node, promptCatalog],
|
||||
|
||||
@@ -82,7 +82,7 @@ import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { asc, desc, eq, sql } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { promptLocationLayer } from "./fixture/prompt-location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { InstanceMap } from "@opencode-ai/core/instance-map"
|
||||
import { Expected } from "./lib/session-message"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { agentHost, catalogHost, host } from "./plugin/host"
|
||||
@@ -483,7 +483,7 @@ const layer = Layer.unwrap(
|
||||
[
|
||||
...replacements,
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[LocationServiceMap.node, promptLocationLayer],
|
||||
[InstanceMap.node, promptLocationLayer],
|
||||
[Catalog.node, promptCatalog],
|
||||
[SessionExecution.node, execution],
|
||||
],
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, LayerMap } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import type { LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { InstanceMap } from "@opencode-ai/core/instance-map"
|
||||
import { stubLocations } from "./fixture/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
@@ -40,20 +40,13 @@ const skills = Layer.mergeAll(
|
||||
}),
|
||||
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
|
||||
)
|
||||
const locations = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
() =>
|
||||
// The skill endpoint only needs the location-scoped Skill service.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
skills as unknown as Layer.Layer<LocationServices>,
|
||||
),
|
||||
)
|
||||
// The skill endpoint only needs the location-scoped Skill service.
|
||||
const locations = stubLocations(skills)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[LocationServiceMap.node, locations],
|
||||
[InstanceMap.node, locations],
|
||||
[Project.node, projects],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
|
||||
@@ -17,7 +17,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { InstanceMap } from "@opencode-ai/core/instance-map"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -148,7 +148,7 @@ const nodes = LayerNode.group([
|
||||
Session.node,
|
||||
SessionExecution.node,
|
||||
PluginRuntime.providerNode,
|
||||
LocationServiceMap.node,
|
||||
InstanceMap.node,
|
||||
filesystem,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
@@ -212,7 +212,7 @@ const withSession = <A, E, R>(directory: string, body: (registry: Tool.Interface
|
||||
location,
|
||||
model: sessionModel,
|
||||
})
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const locationLayer = locations.get(location)
|
||||
return yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { InstanceMap } from "@opencode-ai/core/instance-map"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
@@ -112,7 +112,7 @@ const nodes = LayerNode.group([
|
||||
Session.node,
|
||||
SessionExecution.node,
|
||||
PluginRuntime.providerNode,
|
||||
LocationServiceMap.node,
|
||||
InstanceMap.node,
|
||||
])
|
||||
const replacements = [
|
||||
[SessionExecution.node, executionNode],
|
||||
@@ -123,7 +123,7 @@ const it = testEffect(AppNodeBuilder.build(nodes, [...replacements, [PluginSuper
|
||||
|
||||
const withSubagent = (location: Location.Ref) =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(locations.get(location)))
|
||||
yield* Agent.Service.use((agents) =>
|
||||
agents.transform((draft) => {
|
||||
@@ -159,7 +159,7 @@ describe("SubagentTool", () => {
|
||||
const parent = yield* session.create({ location })
|
||||
yield* withSubagent(parent.location)
|
||||
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toContain(SubagentTool.name)
|
||||
expect(
|
||||
@@ -194,7 +194,7 @@ describe("SubagentTool", () => {
|
||||
const root = yield* sessions.create({ location })
|
||||
const parent = yield* sessions.create({ parentID: root.id, title: "parent" })
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
|
||||
expect(
|
||||
@@ -236,7 +236,7 @@ describe("SubagentTool", () => {
|
||||
const root = yield* sessions.create({ location })
|
||||
const parent = yield* sessions.create({ parentID: root.id, title: "parent", model: parentModel })
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
|
||||
const settled = yield* executeTool(registry, {
|
||||
@@ -276,7 +276,7 @@ describe("SubagentTool", () => {
|
||||
const sessions = yield* Session.Service
|
||||
const parent = yield* sessions.create({ location, model: parentModel })
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
const progress: Tool.Metadata[] = []
|
||||
|
||||
@@ -339,7 +339,7 @@ describe("SubagentTool", () => {
|
||||
const sessions = yield* Session.Service
|
||||
const parent = yield* sessions.create({ location, model: parentModel })
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
|
||||
const first = yield* executeTool(registry, {
|
||||
@@ -400,7 +400,7 @@ describe("SubagentTool", () => {
|
||||
model: childModel,
|
||||
})
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
const jobs = yield* Job.Service
|
||||
yield* jobs.start({ id: child.id, type: SubagentTool.name, run: Effect.never })
|
||||
@@ -459,7 +459,7 @@ describe("SubagentTool", () => {
|
||||
model: parentModel,
|
||||
})
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
const call = (sessionID: Session.ID, id: string, agent = "reviewer") =>
|
||||
executeTool(registry, {
|
||||
@@ -521,7 +521,7 @@ describe("SubagentTool", () => {
|
||||
const sessions = yield* Session.Service
|
||||
const parent = yield* sessions.create({ location })
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
|
||||
expect(
|
||||
@@ -558,7 +558,7 @@ describe("SubagentTool", () => {
|
||||
const sessions = yield* Session.Service
|
||||
const parent = yield* sessions.create({ location })
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
const bus = yield* Bus.Service
|
||||
const admitted = yield* bus.subscribe(SessionEvent.InboxEnqueued).pipe(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { Effect, Option, RcMap } from "effect"
|
||||
import { InstanceMap } from "@opencode-ai/core/instance-map"
|
||||
import { Effect, Option } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { requestRef } from "../location"
|
||||
@@ -9,14 +9,14 @@ export const DebugHandler = HttpApiBuilder.group(Api, "server.debug", (handlers)
|
||||
.handle(
|
||||
"debug.location",
|
||||
Effect.fn(function* () {
|
||||
const locations = Option.getOrThrow(yield* Effect.serviceOption(LocationServiceMap.Service))
|
||||
return Array.from(yield* RcMap.keys(locations.rcMap))
|
||||
const locations = Option.getOrThrow(yield* Effect.serviceOption(InstanceMap.Service))
|
||||
return (yield* locations.entries).map((entry) => entry.location)
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"debug.location.evict",
|
||||
Effect.fn(function* (ctx) {
|
||||
const locations = Option.getOrThrow(yield* Effect.serviceOption(LocationServiceMap.Service))
|
||||
const locations = Option.getOrThrow(yield* Effect.serviceOption(InstanceMap.Service))
|
||||
// Resolve through requestRef so the key matches the shape the location
|
||||
// middleware cached the services under.
|
||||
yield* locations.invalidate(requestRef(ctx.request))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Generate } from "@opencode-ai/core/generate"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { InstanceMap } from "@opencode-ai/core/location-services"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { InvalidRequestError, ServiceUnavailableError } from "@opencode-ai/protocol/errors"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
@@ -20,7 +20,7 @@ const flushPlugins = pluginReadiness(
|
||||
export const GenerateHandler = HttpApiBuilder.group(Api, "server.generate", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const services = locations.get(Location.Ref.make({ directory: AbsolutePath.make(global.config) }))
|
||||
return handlers.handle(
|
||||
"generate.text",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { InstanceMap } from "@opencode-ai/core/location-services"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpServerRequest } from "effect/unstable/http"
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
|
||||
export type LocationServices = Layer.Success<ReturnType<(typeof LocationServiceMap.Service)["get"]>>
|
||||
export type LocationServices = Layer.Success<ReturnType<(typeof InstanceMap.Service)["get"]>>
|
||||
|
||||
export class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware, { provides: LocationServices }>()(
|
||||
"@opencode/HttpApiLocation",
|
||||
@@ -49,7 +49,7 @@ function decode(input: string) {
|
||||
export const layer = Layer.effect(
|
||||
LocationMiddleware,
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
return LocationMiddleware.of((effect) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { InstanceMap } from "@opencode-ai/core/location-services"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
@@ -25,7 +25,7 @@ export const formLocationLayer = Layer.effect(
|
||||
FormLocationMiddleware,
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
|
||||
return FormLocationMiddleware.of((effect) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -62,12 +62,12 @@ export const formLocationLayer = Layer.effect(
|
||||
|
||||
return yield* effect.pipe(
|
||||
Effect.provide(
|
||||
locations.get(
|
||||
Location.Ref.make({
|
||||
locations.forSession({
|
||||
location: Location.Ref.make({
|
||||
directory: AbsolutePath.make(row.directory),
|
||||
workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { InstanceMap } from "@opencode-ai/core/location-services"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
@@ -25,7 +25,7 @@ export const sessionLocationLayer = Layer.effect(
|
||||
SessionLocationMiddleware,
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
|
||||
return SessionLocationMiddleware.of((effect) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -53,12 +53,12 @@ export const sessionLocationLayer = Layer.effect(
|
||||
|
||||
return yield* effect.pipe(
|
||||
Effect.provide(
|
||||
locations.get(
|
||||
Location.Ref.make({
|
||||
locations.forSession({
|
||||
location: Location.Ref.make({
|
||||
directory: AbsolutePath.make(row.directory),
|
||||
workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Job } from "@opencode-ai/core/job"
|
||||
import { Mcp } from "@opencode-ai/core/mcp/index"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { InstanceMap } from "@opencode-ai/core/instance-map"
|
||||
import { LocationActivity } from "@opencode-ai/core/location-activity"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
@@ -64,7 +64,7 @@ const applicationServiceNodes = [
|
||||
Credential.node,
|
||||
WellKnown.node,
|
||||
PtyEnvironment.node,
|
||||
LocationServiceMap.node,
|
||||
InstanceMap.node,
|
||||
LocationActivity.node,
|
||||
SessionRestart.node,
|
||||
Workspace.node,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import path from "node:path"
|
||||
import { expect } from "bun:test"
|
||||
import type { Instance } from "@opencode-ai/core/instance"
|
||||
import { InstanceMap } from "@opencode-ai/core/instance-map"
|
||||
import { Entry, fromMap } from "@opencode-ai/core/instance-map/internal"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Duration, Effect, Layer, LayerMap } from "effect"
|
||||
import { tempGlobalLayer } from "../../core/test/fixture/global"
|
||||
import { location } from "../../core/test/fixture/location"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerFetch } from "../src/fetch"
|
||||
|
||||
it.live("lists retained locations and evicts through query and header refs without booting", () =>
|
||||
Effect.gen(function* () {
|
||||
const built: Location.Ref[] = []
|
||||
const map = fromMap(
|
||||
yield* LayerMap.make(
|
||||
(entry: Entry) =>
|
||||
Layer.effect(
|
||||
Location.Service,
|
||||
Effect.sync(() => {
|
||||
built.push(entry.location)
|
||||
return location(entry.location)
|
||||
}),
|
||||
// The HTTP debug routes need no other instance services.
|
||||
) as unknown as Layer.Layer<Instance.Services, Instance.Error>,
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
),
|
||||
)
|
||||
const handler = yield* ServerFetch.make(
|
||||
{ database: { path: ":memory:" }, fs: { filewatcher: false } },
|
||||
{
|
||||
overrides: [
|
||||
[Global.node, tempGlobalLayer],
|
||||
[InstanceMap.node, Layer.succeed(InstanceMap.Service, map)],
|
||||
],
|
||||
},
|
||||
)
|
||||
const url = "http://opencode.local/api/debug/location"
|
||||
const list = Effect.promise(async () => {
|
||||
const response = await handler(new Request(url))
|
||||
expect(response.status).toBe(200)
|
||||
return response.json()
|
||||
})
|
||||
expect(yield* list).toEqual([])
|
||||
const local = Location.Ref.make({ directory: AbsolutePath.make(path.resolve("debug-repo")) })
|
||||
const workspaceID = Workspace.ID.make("wrk_team:alpha%3A")
|
||||
const workspace = Location.Ref.make({ ...local, workspaceID })
|
||||
yield* map.contextEffect(local).pipe(Effect.scoped)
|
||||
yield* map.contextEffect(workspace).pipe(Effect.scoped)
|
||||
expect(yield* list).toEqual([local, workspace])
|
||||
|
||||
const query = new URL(url)
|
||||
query.searchParams.set("location[directory]", workspace.directory)
|
||||
query.searchParams.set("location[workspace]", workspaceID)
|
||||
const workspaceEviction = yield* Effect.promise(() => handler(new Request(query, { method: "DELETE" })))
|
||||
expect(workspaceEviction.status).toBe(204)
|
||||
expect(yield* list).toEqual([local])
|
||||
|
||||
const localEviction = yield* Effect.promise(() =>
|
||||
handler(
|
||||
new Request(url, {
|
||||
method: "DELETE",
|
||||
headers: { "x-opencode-directory": encodeURIComponent(local.directory.replaceAll("\\", "/")) },
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(localEviction.status).toBe(204)
|
||||
expect(yield* list).toEqual([])
|
||||
expect((yield* Effect.promise(() => handler(new Request(query, { method: "DELETE" })))).status).toBe(204)
|
||||
expect(built).toEqual([local, workspace])
|
||||
}),
|
||||
)
|
||||
@@ -10,7 +10,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { InstanceMap } from "@opencode-ai/core/location-services"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -244,7 +244,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
|
||||
permission: "simulate_lookup",
|
||||
options: { codemode: false },
|
||||
}
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locations = yield* InstanceMap.Service
|
||||
const [primary, secondary] = yield* Effect.all([
|
||||
Layer.build(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory) }))),
|
||||
Layer.build(locations.get(Location.Ref.make({ directory: AbsolutePath.make(secondDirectory) }))),
|
||||
@@ -699,10 +699,9 @@ const toolLifecycleLayer = (endpoint: string) => {
|
||||
layer: SimulatedProvider.layerDrive({ endpoint, version: "test" }),
|
||||
deps: [SdkPlugins.node],
|
||||
})
|
||||
return AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, provider]),
|
||||
[[Config.node, Config.testLayer()]],
|
||||
)
|
||||
return AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, InstanceMap.node, provider]), [
|
||||
[Config.node, Config.testLayer()],
|
||||
])
|
||||
}
|
||||
|
||||
function messagesFrom(socket: WebSocket) {
|
||||
|
||||
@@ -55,7 +55,7 @@ yield* transform((draft) => mutateAnyConfigField(draft))
|
||||
Configured plugin installation/updates should not block location readiness. Build an initial snapshot from authored config and fast built-ins, then activate slow plugins in the background and coalesce their resulting reload requests.
|
||||
|
||||
```ts
|
||||
LocationServiceMap.get(ref)
|
||||
InstanceMap.get(ref)
|
||||
→ build location layer
|
||||
→ Config.layer reads authored documents
|
||||
→ merge authored documents
|
||||
@@ -214,7 +214,7 @@ yield* transform(update)
|
||||
Configured plugin installation/updates should not block location readiness. Build an initial catalog from immediately available sources, then activate slow plugins in the background and coalesce refresh requests.
|
||||
|
||||
```ts
|
||||
LocationServiceMap.get(ref)
|
||||
InstanceMap.get(ref)
|
||||
→ build location layer
|
||||
→ Catalog.layer creates empty catalog state
|
||||
→ PluginBoot.layer activates immediately available plugins
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ Manual compaction and Session movement use the same inbox as control items. Each
|
||||
|
||||
## Execution Is Process-Local
|
||||
|
||||
`SessionExecution` is process-global and keyed only by Session ID. At drain start it loads the Session, enters its Location through `LocationServiceMap`, and invokes the Location-scoped runner. The runner, model resolution, tools, permissions, plugins, and filesystem remain Location-scoped.
|
||||
`SessionExecution` is process-global and keyed only by Session ID. At drain start it loads the Session, selects its instance through `InstanceMap.forSession`, and invokes the Location-scoped runner. Default assignment still shares one instance per canonical Location. The runner, model resolution, tools, permissions, plugins, and filesystem remain Location-scoped.
|
||||
|
||||
`SessionRunCoordinator` provides the local ownership rules:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user