Compare commits

...
49 changed files with 831 additions and 411 deletions
+1 -1
View File
@@ -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.
+3 -3
View File
@@ -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
View File
@@ -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
}),
)
},
+7 -7
View File
@@ -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>) {
+7
View File
@@ -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>()
+30
View File
@@ -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 }))),
),
}
}
+2
View File
@@ -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,
+18 -24
View File
@@ -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],
})
-18
View File
@@ -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"
+11 -25
View File
@@ -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,
),
)
}
+20
View File
@@ -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
+3 -3
View File
@@ -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)
+14 -18
View File
@@ -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 -4
View File
@@ -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. */
+1 -1
View File
@@ -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, {
+4 -4
View File
@@ -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))
+21 -1
View File
@@ -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 {
+4 -12
View File
@@ -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 })),
)
+3 -5
View File
@@ -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) }))),
),
),
),
+216
View File
@@ -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([])
}),
)
})
+20 -14
View File
@@ -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"))
+22 -17
View File
@@ -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
+88 -63
View File
@@ -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)
})
})
+60
View File
@@ -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 -16
View File
@@ -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],
],
+2 -2
View File
@@ -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],
],
),
+5 -12
View File
@@ -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),
+5 -10
View File
@@ -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
+26 -33
View File
@@ -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],
],
),
)
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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],
+2 -2
View File
@@ -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],
],
+6 -13
View File
@@ -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],
],
+3 -3
View File
@@ -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
+12 -12
View File
@@ -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(
+5 -5
View File
@@ -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))
+2 -2
View File
@@ -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",
+3 -3
View File
@@ -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,
}),
),
}),
),
)
}),
+2 -2
View File
@@ -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,
+76
View File
@@ -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) {
+2 -2
View File
@@ -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
View File
@@ -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: