Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/effect/app-node-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const instances = makeGlobalNode({
const locations = yield* LocationServiceMap.Service
return Instance.Service.of({
provide: (session) => Effect.provide(locations.get(session.location)),
provideCached: (session) => Instance.cached(locations, LocationServiceMap.canonical(session.location)),
})
}),
),
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ import { ToolOutput } from "./tool-output.js"
import { Vcs } from "./vcs.js"

export * as Instance from "./instance.js"
export { Service, node, type Interface } from "./instance/service.js"
export { Service, node, cached, type Interface } from "./instance/service.js"

const nodes = [
Location.node,
Expand Down
17 changes: 16 additions & 1 deletion packages/core/src/instance/service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export * as Instance from "./service.js"
export type { Services } from "../instance.js"

import { Context, type Effect } from "effect"
import { Context, Effect, LayerMap, Option, RcMap } from "effect"
import type { Session } from "@opencode/schema/session"
import { Node } from "@opencode/util/effect/app-node"
import { LayerNode } from "@opencode/util/effect/layer-node"
Expand All @@ -12,6 +12,21 @@ export interface Interface {
readonly provide: (
session: Session.Info,
) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E | Error, Exclude<R, Services>>
readonly provideCached: (
session: Session.Info,
) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<Option.Option<A>, E | Error, Exclude<R, Services>>
}

/** Borrow an existing graph for the duration of a read without constructing a missing entry. */
export function cached<K>(map: LayerMap.LayerMap<K, Services, Error>, key: K) {
return <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.scoped(
Effect.gen(function* () {
const context = yield* RcMap.getOption(map.rcMap, key)
if (Option.isNone(context)) return Option.none<A>()
return Option.some(yield* Effect.provide(effect, context.value))
}),
)
}

export class Service extends Context.Service<Service, Interface>()("@opencode/Instance") {}
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/location-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export function buildLocationServiceMap(
Instance.node.replace(
Layer.succeed(Instance.Service, {
provide: (session) => Effect.provide(map.get(session.location)),
provideCached: (session) => Instance.cached(map, LocationServiceMap.canonical(session.location)),
}),
),
...replacements,
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/instance-vanilla.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const instances = Layer.effect(
Instance.node.replace(
Layer.succeed(Instance.Service, {
provide: (session) => Effect.provide(map.get(session.location)),
provideCached: (session) => Instance.cached(map, session.location),
}),
),
]
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/plugin/supervisor-reload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ const instances = Layer.effect(
Instance.node.replace(
Layer.succeed(Instance.Service, {
provide: (session) => Effect.provide(map.get(session.location)),
provideCached: (session) => Instance.cached(map, session.location),
}),
),
]
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/plugin/supervisor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const instances = Layer.effect(
Instance.node.replace(
Layer.succeed(Instance.Service, {
provide: (session) => Effect.provide(map.get(session.location)),
provideCached: (session) => Instance.cached(map, session.location),
}),
),
]
Expand Down
2 changes: 2 additions & 0 deletions packages/core/test/session-generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,8 @@ const setup = Effect.gen(function* () {
session,
instructions: yield* instructionBuiltIns.load(),
instances: Instance.Service.of({
// Generation has no cached graph in this fixture.
provideCached: () => () => Effect.succeedNone,
// Generation only exercises the Location's model context.
provide: () =>
Effect.provide(
Expand Down
6 changes: 6 additions & 0 deletions packages/core/test/session-move.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ const sourceProbe = (options: { execution?: boolean } = {}) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
return Instance.Service.of({
provideCached: (session) =>
Instance.cached(locations, LocationServiceMap.canonical(session.location)),
provide: (session) => (effect) =>
Effect.gen(function* () {
if (session.location.directory === source) {
Expand Down Expand Up @@ -304,6 +306,10 @@ describe("Session.move", () => {
{ idleTimeToLive: Duration.infinity },
)
const selector = Instance.Service.of({
provideCached: (session) =>
session.id === selectedID && session.location.directory === source.directory
? Instance.cached(privateInstances, session.id)
: Instance.cached(locations, LocationServiceMap.canonical(session.location)),
provide: (session) =>
Effect.provide(
session.id === selectedID && session.location.directory === source.directory
Expand Down
2 changes: 2 additions & 0 deletions packages/core/test/session-owned.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ const setup = Effect.fnUntraced(function* (options?: {
const instances = Instance.Service.of({
// This fixture supplies only the instance services exercised by Session.
provide: (session) => Effect.provide(servicesFor(session.location) as Layer.Layer<Instance.Services>),
// This fixture has no cache and is only used for active Session operations.
provideCached: () => () => Effect.succeedNone,
})
const sessions = yield* Session.make().pipe(
Effect.satisfiesServicesType<
Expand Down
18 changes: 8 additions & 10 deletions packages/protocol/src/groups/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -794,16 +794,14 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S, FormI ext
HttpApiEndpoint.get("session.form.list", "/api/session/:sessionID/form", {
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.Array(Form.Info) }),
error: SessionNotFoundError,
})
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "session.form.list",
summary: "List session forms",
description: "Retrieve pending forms for a session.",
}),
),
error: [SessionNotFoundError, InvalidRequestError, LocationNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.form.list",
summary: "List session forms",
description: "Retrieve pending forms for a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.form.create", "/api/session/:sessionID/form", {
Expand Down
5 changes: 3 additions & 2 deletions packages/sdk/src/internal/instances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export function layer(options: Options, replacements: () => LayerNode.Replacemen
...LocationServiceMap.canonical(session.location),
})
const provide = (session: Session.Info) => Effect.provide(instances.get(key(session)))
const provideCached = (session: Session.Info) => Instance.cached(instances, key(session))
const instances: LayerMap.LayerMap<
ReturnType<typeof key>,
Instance.Services,
Expand All @@ -72,7 +73,7 @@ export function layer(options: Options, replacements: () => LayerNode.Replacemen
...replacements(),
// Instances borrow this selector and the host's Location map instead of retaining
// their Layer scopes; retaining the selector would block its shutdown on its own entries.
Instance.node.replace(Layer.succeed(Instance.Service, { provide })),
Instance.node.replace(Layer.succeed(Instance.Service, { provide, provideCached })),
LocationServiceMap.node.replace(Layer.succeed(LocationServiceMap.Service, locations)),
],
}).pipe(
Expand Down Expand Up @@ -102,7 +103,7 @@ export function layer(options: Options, replacements: () => LayerNode.Replacemen
),
{ idleTimeToLive: Duration.infinity },
)
return Instance.Service.of({ provide })
return Instance.Service.of({ provide, provideCached })
}),
)
}
8 changes: 7 additions & 1 deletion packages/sdk/test/instances-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Instance } from "@opencode/core/instance/service"
import { Session } from "@opencode/core/session"
import { Location } from "@opencode/schema/location"
import { AbsolutePath } from "@opencode/schema/schema"
import { Context, Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { Context, Deferred, Effect, Exit, Fiber, Layer, Option } from "effect"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
import { testEffect } from "../../core/test/lib/effect"
import { EmbeddedHost } from "../src/internal/host"
Expand Down Expand Up @@ -47,16 +47,22 @@ it.live("a cancelled borrower cannot strand a later failed instance construction
location: Location.Ref.make({ directory: AbsolutePath.make(directory.path) }),
})
expect(acquired).toEqual([])
expect(yield* Effect.void.pipe(instances.provideCached(session))).toEqual(Option.none())
expect(acquired).toEqual([])

const borrower = yield* Effect.void.pipe(instances.provide(session), Effect.forkScoped)
const lookup = yield* Deferred.await(started)
const cached = yield* Effect.void.pipe(instances.provideCached(session), Effect.forkScoped)
yield* Effect.yieldNow
yield* Fiber.interrupt(borrower)
expect(released).toEqual([])
yield* Deferred.succeed(release, undefined)
expect(Exit.isFailure(yield* Fiber.await(lookup).pipe(Effect.timeout("1 second")))).toBe(true)
expect(Exit.isFailure(yield* Fiber.await(cached).pipe(Effect.timeout("1 second")))).toBe(true)
expect(released).toEqual([1])

yield* Effect.void.pipe(instances.provide(session))
expect(yield* Effect.succeed("warm").pipe(instances.provideCached(session))).toEqual(Option.some("warm"))
expect(acquired).toEqual([1, 2])
expect(released).toEqual([1])

Expand Down
20 changes: 18 additions & 2 deletions packages/sdk/test/instances.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,11 @@ test("Promise instances are lazy, share by key and Location, and stay isolated b
expect(await second.sessions.get({ sessionID })).toEqual(separateHost)
expect(configured).toEqual([])
expect(setups).toEqual([])
// Permission and form lists read instance services, so they acquire the Session's instance.
// Pending lists only borrow existing instances; cold hydration does not configure one.
expect(await first.permission.list({ sessionID })).toEqual([])
expect(await first.session.form.list({ sessionID })).toEqual([])
expect(configured).toEqual(["first:alpha"])
expect(configured).toEqual([])
expect(setups).toEqual([])

await Promise.all(
[
Expand All @@ -111,6 +112,21 @@ test("Promise instances are lazy, share by key and Location, and stay isolated b
}),
)

const alphaForm = await first.session.form.create({
sessionID: original.id,
title: "Alpha",
fields: [{ key: "answer", type: "string" }],
})
const betaForm = await first.session.form.create({
sessionID: separateKey.id,
title: "Beta",
fields: [{ key: "answer", type: "string" }],
})
expect(await first.session.form.list({ sessionID: original.id })).toEqual([alphaForm])
expect(await first.session.form.list({ sessionID: separateKey.id })).toEqual([betaForm])
expect(await first.session.form.list({ sessionID: sameKey.id })).toEqual([])
expect(await second.session.form.list({ sessionID: separateHost.id })).toEqual([])

await first.sessions.switchAgent({ sessionID, agent: "plan" })
const fork = await first.sessions.fork({ sessionID })
expect(fork.metadata).toEqual(original.metadata)
Expand Down
10 changes: 5 additions & 5 deletions packages/server/src/handlers/permission.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Instance } from "@opencode/core/instance/service"
import { Location } from "@opencode/core/location"
import { Instance } from "@opencode/core/instance/service"
import { Permission } from "@opencode/core/permission"
import { PermissionSaved } from "@opencode/core/permission/saved"
import { Session } from "@opencode/core/session"
import { Effect } from "effect"
import { Effect, Option } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { PermissionNotFoundError } from "@opencode/protocol/errors"
Expand All @@ -16,8 +16,8 @@ function missingRequest(id: Permission.ID) {

export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", (handlers) =>
Effect.gen(function* () {
const instances = yield* Instance.Service
const sessions = yield* Session.Service
const instances = yield* Instance.Service
const requireOwnedRequest = Effect.fnUntraced(function* (
sessionID: Permission.Request["sessionID"],
requestID: Permission.ID,
Expand Down Expand Up @@ -62,8 +62,8 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission",
const session = yield* sessionInfo(sessions, ctx.params.sessionID)
const requests = yield* Permission.Service.use((permission) =>
permission.forSession(ctx.params.sessionID),
).pipe(instances.provide(session), locationErrors)
return { data: requests }
).pipe(instances.provideCached(session), locationErrors)
return { data: Option.getOrElse(requests, () => []) }
}),
)
.handle(
Expand Down
20 changes: 16 additions & 4 deletions packages/server/src/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { SessionTitle } from "@opencode/core/session/title"
import { SessionTransfer } from "@opencode/core/session/transfer"
import { InstructionEntry } from "@opencode/core/session/instruction-entry"
import { Form } from "@opencode/core/form"
import { DateTime, Effect, Stream } from "effect"
import { Instance } from "@opencode/core/instance/service"
import { LocationServiceMap } from "@opencode/core/location-services"
import { DateTime, Effect, Option, Stream } from "effect"
import { HttpServerRequest } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { SessionsCursor } from "@opencode/protocol/groups/session"
Expand All @@ -23,7 +26,7 @@ import {
SkillNotFoundError,
} from "@opencode/protocol/errors"
import { AbsolutePath } from "@opencode/core/schema"
import { locationErrors } from "../location"
import { cachedLocation, locationErrors, requestRef, sessionInfo } from "../location"
import { failedMessageDecode, failedSnapshot, missingMessage, missingSession } from "./session-error"

const DefaultSessionsLimit = 50
Expand All @@ -35,6 +38,8 @@ function missingForm(id: Form.ID) {
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
Effect.gen(function* () {
const session = yield* Session.Service
const locations = yield* LocationServiceMap.Service
const instances = yield* Instance.Service
const transfer = yield* SessionTransfer.Service
const requireOwnedForm = Effect.fnUntraced(function* (sessionID: Form.Info["sessionID"], formID: Form.ID) {
const form = yield* Form.Service
Expand Down Expand Up @@ -645,8 +650,15 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
.handle(
"session.form.list",
Effect.fn(function* (ctx) {
const form = yield* Form.Service
return { data: yield* form.list({ sessionID: ctx.params.sessionID }) }
const read = Form.Service.use((form) => form.list({ sessionID: ctx.params.sessionID }))
const forms =
ctx.params.sessionID === "global"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side effect of skipping the location start: a session whose folder has been deleted now gets 200 {"data":[]} from /form and /permission (and from /api/session/global/form with a missing directory) instead of the LocationNotFoundError added in #52668. fetch.test.ts was changed to expect this. Returning an empty list seems reasonable for a read of pending prompts, but please confirm it's intended and mention it in the description. LocationNotFoundError is now listed for session.form.list, but it can only happen if a start already in progress fails.

? yield* cachedLocation(locations, requestRef(yield* HttpServerRequest.HttpServerRequest), read)
: yield* read.pipe(
instances.provideCached(yield* sessionInfo(session, ctx.params.sessionID)),
locationErrors,
)
return { data: Option.getOrElse(forms, () => []) }
}),
)
.handle(
Expand Down
9 changes: 9 additions & 0 deletions packages/server/src/location.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { FileSystem } from "@opencode/core/filesystem"
import { Instance } from "@opencode/core/instance/service"
import { Location } from "@opencode/core/location"
import { LocationServiceMap } from "@opencode/core/location-services"
import { AbsolutePath } from "@opencode/core/schema"
Expand All @@ -11,6 +12,14 @@ import { missingSession } from "./handlers/session-error"

export type LocationServices = Layer.Success<ReturnType<(typeof LocationServiceMap.Service)["get"]>>

export function cachedLocation<A, E>(
locations: typeof LocationServiceMap.Service.Service,
ref: Location.Ref,
read: Effect.Effect<A, E, LocationServices>,
) {
return read.pipe(Instance.cached(locations, LocationServiceMap.canonical(ref)), locationErrors)
}

export class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware, { provides: LocationServices }>()(
"@opencode/HttpApiLocation",
{ error: [LocationNotFoundError] },
Expand Down
26 changes: 17 additions & 9 deletions packages/server/test/fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,7 @@ it.live("returns LocationNotFoundError for a missing folder and recovers once it
const session = Schema.decodeUnknownSync(Schema.Struct({ data: Session.Info }))(
yield* Effect.promise(() => created.json()),
).data
const endpoints = [
"/api/model",
"/api/integration",
`/api/session/${session.id}/permission`,
`/api/experimental/session/${session.id}/instructions/entries`,
`/api/session/${session.id}/form`,
"/api/session/global/form",
]
const endpoints = ["/api/model", "/api/integration", `/api/experimental/session/${session.id}/instructions/entries`]
for (const endpoint of endpoints) {
const response = yield* Effect.promise(() =>
handler(
Expand All @@ -61,6 +54,21 @@ it.live("returns LocationNotFoundError for a missing folder and recovers once it
message: `Location not found: ${directory}`,
})
}
for (const endpoint of [
`/api/session/${session.id}/permission`,
`/api/session/${session.id}/form`,
"/api/session/global/form",
]) {
const response = yield* Effect.promise(() =>
handler(
new Request(`http://opencode.local${endpoint}`, {
headers: { "x-opencode-directory": encodeURIComponent(directory) },
}),
),
)
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toEqual({ data: [] })
}
yield* Effect.promise(() => fs.mkdir(directory))
const recovered = yield* Effect.promise(() =>
handler(
Expand Down Expand Up @@ -506,7 +514,7 @@ it.live("routes pending requests through the Session's instance", () =>
)
expect(global.status).toBe(200)
expect(yield* Effect.promise(() => global.json())).toEqual({ data: [] })
expect(yield* loaded()).toEqual([{ directory: process.cwd() }])
expect(yield* loaded()).toEqual([])

const createdForm = yield* Effect.promise(() =>
handler(
Expand Down
Loading
Loading