diff --git a/packages/opencode/src/provider/auth-service.ts b/packages/opencode/src/provider/auth-service.ts index a97ce1840c..a49b92f52d 100644 --- a/packages/opencode/src/provider/auth-service.ts +++ b/packages/opencode/src/provider/auth-service.ts @@ -5,19 +5,10 @@ import { filter, fromEntries, map, mapValues, pipe } from "remeda" import type { AuthOuathResult } from "@opencode-ai/plugin" import { NamedError } from "@opencode-ai/util/error" import * as Auth from "@/auth/service" +import * as ScopedState from "@/util/scoped-state" import { ProviderID } from "./schema" import z from "zod" -const state = Instance.state(async () => { - const methods = pipe( - await Plugin.list(), - filter((x) => x.auth?.provider !== undefined), - map((x) => [x.auth!.provider, x.auth!] as const), - fromEntries(), - ) - return { methods, pending: {} as Record } -}) - export type Method = { type: "oauth" | "api" label: string @@ -71,10 +62,23 @@ export class ProviderAuthService extends ServiceMap.Service Instance.directory, + lookup: () => + Effect.promise(async () => { + const methods = pipe( + await Plugin.list(), + filter((x) => x.auth?.provider !== undefined), + map((x) => [x.auth!.provider, x.auth!] as const), + fromEntries(), + ) + return { methods, pending: {} as Record } + }), + }) const methods = Effect.fn("ProviderAuthService.methods")(() => - Effect.promise(() => - state().then((x) => + ScopedState.get(state).pipe( + Effect.map((x) => mapValues(x.methods, (y) => y.methods.map( (z): Method => ({ @@ -91,15 +95,11 @@ export class ProviderAuthService extends ServiceMap.Service state().then((x) => x.methods[input.providerID])) + const item = (yield* ScopedState.get(state)).methods[input.providerID] const method = item.methods[input.method] if (method.type !== "oauth") return const result = yield* Effect.promise(() => method.authorize()) - yield* Effect.promise(() => - state().then((x) => { - x.pending[input.providerID] = result - }), - ) + ;(yield* ScopedState.get(state)).pending[input.providerID] = result return { url: result.url, method: result.method, @@ -112,7 +112,7 @@ export class ProviderAuthService extends ServiceMap.Service state().then((x) => x.pending[input.providerID])) + const match = (yield* ScopedState.get(state)).pending[input.providerID] if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID })) const result = diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index bc53d874c4..d513085392 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -5,6 +5,9 @@ import { fn } from "@/util/fn" import * as S from "./auth-service" import { ProviderID } from "./schema" +// Separate runtime: ProviderAuthService can't join the shared runtime because +// runtime.ts → auth-service.ts → provider/auth.ts creates a circular import. +// AuthService is stateless file I/O so the duplicate instance is harmless. const rt = ManagedRuntime.make(S.ProviderAuthService.defaultLayer) function runPromise(f: (service: S.ProviderAuthService.Service) => Effect.Effect) { diff --git a/packages/opencode/src/util/scoped-state.ts b/packages/opencode/src/util/scoped-state.ts new file mode 100644 index 0000000000..b709ce6463 --- /dev/null +++ b/packages/opencode/src/util/scoped-state.ts @@ -0,0 +1,39 @@ +import { Effect, ScopedCache, Scope } from "effect" + +const TypeId = Symbol.for("@opencode/ScopedState") + +export interface ScopedState { + readonly [TypeId]: typeof TypeId + readonly root: () => string + readonly cache: ScopedCache.ScopedCache +} + +export const make = (input: { + root: () => string + lookup: (key: string) => Effect.Effect + release?: (value: A, key: string) => Effect.Effect +}): Effect.Effect, never, Scope.Scope | R> => + ScopedCache.make({ + capacity: Number.POSITIVE_INFINITY, + lookup: (key) => + Effect.acquireRelease(input.lookup(key), (value) => (input.release ? input.release(value, key) : Effect.void)), + }).pipe( + Effect.map((cache) => ({ + [TypeId]: TypeId, + root: input.root, + cache, + })), + ) + +export const get = (self: ScopedState) => ScopedCache.get(self.cache, self.root()) + +export const getAt = (self: ScopedState, key: string) => ScopedCache.get(self.cache, key) + +export const invalidate = (self: ScopedState) => ScopedCache.invalidate(self.cache, self.root()) + +export const invalidateAt = (self: ScopedState, key: string) => + ScopedCache.invalidate(self.cache, key) + +export const has = (self: ScopedState) => ScopedCache.has(self.cache, self.root()) + +export const hasAt = (self: ScopedState, key: string) => ScopedCache.has(self.cache, key) diff --git a/packages/opencode/test/util/scoped-state.test.ts b/packages/opencode/test/util/scoped-state.test.ts new file mode 100644 index 0000000000..b54c4c89f9 --- /dev/null +++ b/packages/opencode/test/util/scoped-state.test.ts @@ -0,0 +1,164 @@ +import { expect, test } from "bun:test" +import { Effect, Fiber } from "effect" + +import * as ScopedState from "../../src/util/scoped-state" + +test("ScopedState caches values for the current root", async () => { + let key = "a" + let n = 0 + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const state = yield* ScopedState.make({ + root: () => key, + lookup: () => Effect.sync(() => ({ n: ++n })), + }) + + const a = yield* ScopedState.get(state) + const b = yield* ScopedState.get(state) + + expect(a).toBe(b) + expect(n).toBe(1) + }), + ), + ) +}) + +test("ScopedState isolates values by root", async () => { + let key = "a" + let n = 0 + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const state = yield* ScopedState.make({ + root: () => key, + lookup: (root) => Effect.sync(() => ({ root, n: ++n })), + }) + + const a = yield* ScopedState.get(state) + key = "b" + const b = yield* ScopedState.get(state) + key = "a" + const a2 = yield* ScopedState.get(state) + + expect(a).toBe(a2) + expect(a).not.toBe(b) + expect(n).toBe(2) + }), + ), + ) +}) + +test("ScopedState.invalidate refreshes the current root", async () => { + let n = 0 + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const state = yield* ScopedState.make({ + root: () => "a", + lookup: () => Effect.sync(() => ({ n: ++n })), + }) + + const a = yield* ScopedState.get(state) + yield* ScopedState.invalidate(state) + const b = yield* ScopedState.get(state) + + expect(a).not.toBe(b) + expect(n).toBe(2) + }), + ), + ) +}) + +test("ScopedState.invalidateAt only refreshes the targeted root", async () => { + let key = "a" + let n = 0 + const seen: string[] = [] + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const state = yield* ScopedState.make({ + root: () => key, + lookup: (root) => Effect.sync(() => ({ root, n: ++n })), + release: (value, root) => + Effect.sync(() => { + seen.push(`${root}:${value.n}`) + }), + }) + + const a = yield* ScopedState.get(state) + key = "b" + const b = yield* ScopedState.get(state) + yield* ScopedState.invalidateAt(state, "a") + key = "a" + const a2 = yield* ScopedState.get(state) + key = "b" + const b2 = yield* ScopedState.get(state) + + expect(a).not.toBe(a2) + expect(b).toBe(b2) + expect(seen).toEqual(["a:1"]) + }), + ), + ) +}) + +test("ScopedState dedupes concurrent lookups for the same root", async () => { + const gate = Promise.withResolvers() + let n = 0 + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const state = yield* ScopedState.make({ + root: () => "a", + lookup: () => + Effect.promise(async () => { + n += 1 + await gate.promise + return { n } + }), + }) + + const fiber = yield* Effect.all([ScopedState.get(state), ScopedState.get(state)], { concurrency: 2 }).pipe( + Effect.forkChild({ startImmediately: true }), + ) + + yield* Effect.promise(() => Promise.resolve()) + expect(n).toBe(1) + + gate.resolve() + const [a, b] = yield* Fiber.join(fiber) + expect(a).toBe(b) + }), + ), + ) +}) + +test("ScopedState runs release when the surrounding scope closes", async () => { + const seen: string[] = [] + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const state = yield* ScopedState.make({ + root: () => "a", + lookup: (root) => Effect.sync(() => ({ root })), + release: (value, root) => + Effect.sync(() => { + seen.push(`${root}:${value.root}`) + }), + }) + + yield* ScopedState.get(state) + yield* ScopedState.getAt(state, "b") + }), + ), + ) + + expect(seen.sort()).toEqual(["a:a", "b:b"]) +})