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
30 changes: 22 additions & 8 deletions packages/app/src/session/requests/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,21 @@ export function createSessionRequestModel() {
const data = useData()
const language = useLanguage()
const settings = useSettings()

const familySessions = createMemo(() => {
const id = params.id

if (!id) return []

return [...new Set([id, ...data.session.family(id)])].flatMap((sessionID) => {
const info = data.session.get(sessionID)

return info ? [info] : []
})
})

const treeIDs = createMemo(() => sessionTreeIDs(familySessions(), params.id))

createEffect(() => {
const id = params.id

Expand All @@ -28,16 +43,15 @@ export function createSessionRequestModel() {
)
})
createEffect(() => {
const id = params.id
if (serverSDK.connection.status() !== "connected") return
const ids = treeIDs()

if (!id || serverSDK.connection.status() !== "connected") return
void Promise.all(
sessionTreeIDs(data.session.list(), id).map((sessionID) => data.session.form.sync(sessionID)),
).catch(() => undefined)
if (!ids.length) return
void Promise.all(ids.map((sessionID) => data.session.form.sync(sessionID))).catch(() => undefined)
})

const formRequest = createMemo((): FormInfo | undefined => {
return sessionFormRequest(data.session.list(), data.session.form.list, params.id)
return sessionFormRequest(familySessions(), data.session.form.list, params.id)
})

// MCP elicitations are owned by the Location rather than a Session, so they are listed per Location.
Expand Down Expand Up @@ -102,7 +116,7 @@ export function createSessionRequestModel() {

if (settings.permissions.autoApproves(project)) return

return sessionPermissionRequest(data.session.list(), data.session.permission.list, id)
return sessionPermissionRequest(familySessions(), data.session.permission.list, id)
})

const blocked = createMemo(() => {
Expand Down Expand Up @@ -131,7 +145,7 @@ export function createSessionRequestModel() {
const running = createSessionBackground({
sessionID: owner,
messages: data.session.message.list,
sessions: data.session.list,
sessions: familySessions,
status: data.session.status,
shells: () => data.shell.list({ directory: sdk().directory }),
})
Expand Down
4 changes: 1 addition & 3 deletions packages/app/src/shell/layout/project-avatar-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ export function useSessionTabAvatarState(

if (!data) return []

if (!root?.()) return data.session.list()
const id = sessionId()

return [...new Set([id, ...data.session.family(id)])].flatMap((id) => {
Expand Down Expand Up @@ -68,8 +67,7 @@ export function useSessionTabAvatarState(
const busy = (id: string) =>
data.session.status(id) === "running" || data.session.pending.list(id).some((item) => item.type !== "synthetic")

if (root?.())
return data.session.list().some((session) => data.session.root(session.id) === sessionId() && busy(session.id))
if (root?.()) return [...new Set([sessionId(), ...data.session.family(sessionId())])].some(busy)

return busy(sessionId())
})
Expand Down
4 changes: 1 addition & 3 deletions packages/core/src/effect/app-node-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@ const instances = makeGlobalNode({
Instance.Service,
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
return Instance.Service.of({
provide: (session) => Effect.provide(locations.get(session.location)),
})
return Instance.fromLocations(locations)
}),
),
deps: [LocationServiceMap.node],
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 @@ -55,7 +55,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, fromLocations, node, provideContextEffectOption, type Interface } from "./instance/service.js"

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

import { Context, type Effect } from "effect"
import { Context, Effect, type LayerMap, Option, Scope } from "effect"
import type { Location } from "@opencode/schema/location"
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 +13,32 @@ 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 provideIfLoaded: (
session: Session.Info,
) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<Option.Option<A>, E, Exclude<R, Services>>
}

export function provideContextEffectOption<K, E0>(
get: (key: K) => Effect.Effect<Option.Option<Context.Context<Services>>, E0, Scope.Scope>,
key: K,
) {
return <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<Option.Option<A>, E, Exclude<R, Services>> =>
Effect.scopedWith((scope) =>
Effect.gen(function* () {
const context = yield* get(key).pipe(Scope.provide(scope), Effect.option, Effect.map(Option.flatten))
if (Option.isNone(context)) return Option.none()
return Option.some(yield* effect.pipe(Effect.provide(context.value)))
}),
)
}

export function fromLocations(
locations: Pick<LayerMap.LayerMap<Location.Ref, Services, Error>, "get" | "contextEffectOption">,
): Interface {
return Service.of({
provide: (session) => Effect.provide(locations.get(session.location)),
provideIfLoaded: (session) => provideContextEffectOption(locations.contextEffectOption, session.location),
})
}

export class Service extends Context.Service<Service, Interface>()("@opencode/Instance") {}
Expand Down
6 changes: 1 addition & 5 deletions packages/core/src/location-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,7 @@ export function buildLocationServiceMap(
}
// Cached instances borrow their owner instead of retaining its Layer scope.
const bindings: LayerNode.Replacements = [
Instance.node.replace(
Layer.succeed(Instance.Service, {
provide: (session) => Effect.provide(map.get(session.location)),
}),
),
Instance.node.replace(Layer.succeed(Instance.Service, Instance.fromLocations(map))),
...replacements,
LocationServiceMap.node.replace(Layer.succeed(LocationServiceMap.Service, map)),
]
Expand Down
6 changes: 1 addition & 5 deletions packages/core/test/instance-vanilla.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,7 @@ const instances = Layer.effect(
Global.node.replace(tempGlobalLayer),
offlineModels,
LocationServiceMap.node.replace(Layer.succeed(LocationServiceMap.Service, map)),
Instance.node.replace(
Layer.succeed(Instance.Service, {
provide: (session) => Effect.provide(map.get(session.location)),
}),
),
Instance.node.replace(Layer.succeed(Instance.Service, Instance.fromLocations(map))),
]
return map
}),
Expand Down
6 changes: 1 addition & 5 deletions packages/core/test/plugin/supervisor-reload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,7 @@ const instances = Layer.effect(
Npm.node.replace(npmLayer),
Watcher.node.replace(Layer.succeed(Watcher.Service, watcher)),
LocationServiceMap.node.replace(Layer.succeed(LocationServiceMap.Service, map)),
Instance.node.replace(
Layer.succeed(Instance.Service, {
provide: (session) => Effect.provide(map.get(session.location)),
}),
),
Instance.node.replace(Layer.succeed(Instance.Service, Instance.fromLocations(map))),
]
return map
}),
Expand Down
6 changes: 1 addition & 5 deletions packages/core/test/plugin/supervisor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,7 @@ const instances = Layer.effect(
offlineModels,
ConfigPluginSource.node.replace(sourceLayer),
LocationServiceMap.node.replace(Layer.succeed(LocationServiceMap.Service, map)),
Instance.node.replace(
Layer.succeed(Instance.Service, {
provide: (session) => Effect.provide(map.get(session.location)),
}),
),
Instance.node.replace(Layer.succeed(Instance.Service, Instance.fromLocations(map))),
]
return map
}),
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/session-generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ const setup = Effect.gen(function* () {
Layer.mock(Plugin.Service, { awaitActivation: Effect.void }),
) as Layer.Layer<Instance.Services>,
),
provideIfLoaded: () => () => Effect.die("Unexpected loaded-only instance lookup"),
}),
}
})
Expand Down
8 changes: 7 additions & 1 deletion packages/core/test/session-move.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ const sourceProbe = (options: { execution?: boolean } = {}) =>
}
return yield* effect.pipe(Effect.provide(locations.get(session.location)))
}),
provideIfLoaded: (session) =>
Instance.provideContextEffectOption(locations.contextEffectOption, session.location),
})
}),
),
Expand Down Expand Up @@ -287,7 +289,7 @@ describe("Session.move", () => {
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const privateInstances = yield* LayerMap.make(
() =>
(_id: Session.ID) =>
Instance.layer(source, {
discovery: false,
replacements: [
Expand All @@ -306,6 +308,10 @@ describe("Session.move", () => {
? privateInstances.get(session.id)
: locations.get(session.location),
),
provideIfLoaded: (session) =>
session.id === selectedID && session.location.directory === source.directory
? Instance.provideContextEffectOption(privateInstances.contextEffectOption, session.id)
: Instance.provideContextEffectOption(locations.contextEffectOption, session.location),
})
const bindings: LayerNode.Replacements = [
...replacements,
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/session-owned.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ 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>),
provideIfLoaded: () => () => Effect.die("Unexpected loaded-only instance lookup"),
})
const sessions = yield* Session.make().pipe(
Effect.satisfiesServicesType<
Expand Down
38 changes: 37 additions & 1 deletion packages/core/test/session-remove.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Effect, Layer, RcMap, Scope } from "effect"
import { Effect, Layer, Option, RcMap, Scope } from "effect"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Database } from "@opencode/core/database/database"
Expand Down Expand Up @@ -115,3 +115,39 @@ describe("Session.remove", () => {
}),
)
})

describe("Instance.provideIfLoaded", () => {
it.live("skips absent instances and scopes loaded borrows without replacing the caller's Scope", () =>

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.

This packs several separate behaviours into one fairly dense case: absent → None without loading, loaded → Some, failure passthrough, the caller's Scope being kept, and invalidation during a borrow. It also lives in session-remove.test.ts, which isn't about provideIfLoaded. The server test in fetch.test.ts already covers the behaviour that matters (no Location is loaded, and loaded ones return real data). Optional: cut this down to the absent/loaded cases, or move it to an instance-focused test file, so it's easier to follow.

Effect.gen(function* () {
const temporary = yield* tmpdirScoped()
const sessions = yield* Session.Service
const instances = yield* Instance.Service
const locations = yield* LocationServiceMap.Service
const scope = yield* Scope.Scope
const session = yield* sessions.create({
location: Location.Ref.make({ directory: AbsolutePath.make(temporary.path) }),
})
const absent = Effect.die("An unloaded instance must not run the effect").pipe(instances.provideIfLoaded(session))

expect(yield* absent).toEqual(Option.none())
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([])
yield* Location.Service.pipe(instances.provide(session))
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([session.location])
expect(yield* Effect.void.pipe(instances.provideIfLoaded(session))).toEqual(Option.some(undefined))
const failure = new Error("Borrowed operation failed")
expect(yield* Effect.fail(failure).pipe(instances.provideIfLoaded(session), Effect.flip)).toBe(failure)

const borrowed = yield* Effect.gen(function* () {
const location = yield* Location.Service
const callerScope = yield* Scope.Scope
expect(callerScope).toBe(scope)
yield* locations.invalidate(session.location)
return location.directory
}).pipe(instances.provideIfLoaded(session), Effect.satisfiesServicesType<Scope.Scope>())

expect(borrowed).toEqual(Option.some(session.location.directory))
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([])
expect(yield* absent).toEqual(Option.none())
}),
)
})
22 changes: 4 additions & 18 deletions packages/protocol/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -4382,18 +4382,11 @@
}
},
"404": {
"description": "SessionNotFoundError | LocationNotFoundError",
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/LocationNotFoundErrorEncoded"
}
]
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
Expand Down Expand Up @@ -9088,18 +9081,11 @@
}
},
"404": {
"description": "SessionNotFoundError | LocationNotFoundError",
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/LocationNotFoundErrorEncoded"
}
]
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
Expand Down
3 changes: 1 addition & 2 deletions packages/protocol/src/groups/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { Context, Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/http-api"
import {
ConflictError,
LocationNotFoundError,
PermissionNotFoundError,
PermissionSavedNotFoundError,
SessionNotFoundError,
Expand Down Expand Up @@ -113,7 +112,7 @@ export const makePermissionGroup = <
HttpApiEndpoint.get("session.permission.list", "/api/session/:sessionID/permission", {
params: { sessionID: Session.ID },
success: Schema.Struct({ data: Schema.Array(Permission.Request) }),
error: [SessionNotFoundError, LocationNotFoundError],
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.permission.list",
Expand Down
16 changes: 7 additions & 9 deletions packages/protocol/src/groups/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -736,15 +736,13 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S, FormI ext
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.",
}),
),
}).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
7 changes: 5 additions & 2 deletions packages/sdk/src/internal/instances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ export function layer(options: Options, replacements: () => LayerNode.Replacemen
...LocationServiceMap.canonical(session.location),
})
const provide = (session: Session.Info) => Effect.provide(instances.get(key(session)))
const provideIfLoaded = (session: Session.Info) =>
Instance.provideContextEffectOption(instances.contextEffectOption, key(session))
const selector = Instance.Service.of({ provide, provideIfLoaded })
const instances: LayerMap.LayerMap<
ReturnType<typeof key>,
Instance.Services,
Expand All @@ -71,7 +74,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, selector)),
LocationServiceMap.node.replace(Layer.succeed(LocationServiceMap.Service, locations)),
],
}).pipe(
Expand Down Expand Up @@ -101,7 +104,7 @@ export function layer(options: Options, replacements: () => LayerNode.Replacemen
),
{ idleTimeToLive: Duration.infinity },
)
return Instance.Service.of({ provide })
return selector
}),
)
}
Loading
Loading