refactor(state): replace ScopedState with InstanceState

Replace the generic ScopedState (keyed by caller-provided root) with
InstanceState that hardcodes Instance.directory and integrates with
the instance dispose/reload lifecycle via a global task registry.

- Parallelize State + InstanceState disposal in reload/dispose
- Use Effect's Record.map and Struct.pick in auth-service
- Flatten nested Effect.gen in OAuth callback flow
- Add docstrings to ProviderAuthService interface
- Add State and InstanceState tests
This commit is contained in:
Kit Langton
2026-03-12 16:03:32 -04:00
parent fedaeea9da
commit 395a9580bd
7 changed files with 305 additions and 236 deletions
+4 -2
View File
@@ -1,3 +1,4 @@
import { Effect } from "effect"
import { Log } from "@/util/log"
import { Context } from "../util/context"
import { Project } from "./project"
@@ -5,6 +6,7 @@ import { State } from "./state"
import { iife } from "@/util/iife"
import { GlobalBus } from "@/bus/global"
import { Filesystem } from "@/util/filesystem"
import * as InstanceState from "@/util/instance-state"
interface Context {
directory: string
@@ -106,7 +108,7 @@ export const Instance = {
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
const directory = Filesystem.resolve(input.directory)
Log.Default.info("reloading instance", { directory })
await State.dispose(directory)
await Promise.all([State.dispose(directory), Effect.runPromise(InstanceState.dispose(directory))])
cache.delete(directory)
const next = track(directory, boot({ ...input, directory }))
emit(directory)
@@ -114,7 +116,7 @@ export const Instance = {
},
async dispose() {
Log.Default.info("disposing instance", { directory: Instance.directory })
await State.dispose(Instance.directory)
await Promise.all([State.dispose(Instance.directory), Effect.runPromise(InstanceState.dispose(Instance.directory))])
cache.delete(Instance.directory)
emit(Instance.directory)
},
+27 -31
View File
@@ -1,11 +1,11 @@
import { Effect, Layer, ServiceMap } from "effect"
import { Effect, Layer, Record, ServiceMap, Struct } from "effect"
import { Instance } from "@/project/instance"
import { Plugin } from "../plugin"
import { filter, fromEntries, map, mapValues, pipe } from "remeda"
import { filter, fromEntries, map, 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 * as InstanceState from "@/util/instance-state"
import { ProviderID } from "./schema"
import z from "zod"
@@ -44,13 +44,20 @@ export type ProviderAuthError =
export namespace ProviderAuthService {
export interface Service {
/** Get available auth methods for each provider (e.g. OAuth, API key). */
readonly methods: () => Effect.Effect<Record<string, Method[]>>
/** Start an OAuth authorization flow for a provider. Returns the URL to redirect to. */
readonly authorize: (input: { providerID: ProviderID; method: number }) => Effect.Effect<Authorization | undefined>
/** Complete an OAuth flow after the user has authorized. Exchanges the code/callback for credentials. */
readonly callback: (input: {
providerID: ProviderID
method: number
code?: string
}) => Effect.Effect<void, ProviderAuthError>
/** Set an API key directly for a provider (no OAuth flow). */
readonly api: (input: { providerID: ProviderID; key: string }) => Effect.Effect<void, Auth.AuthServiceError>
}
}
@@ -62,8 +69,7 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
ProviderAuthService,
Effect.gen(function* () {
const auth = yield* Auth.AuthService
const state = yield* ScopedState.make({
root: () => Instance.directory,
const state = yield* InstanceState.make({
lookup: () =>
Effect.promise(async () => {
const methods = pipe(
@@ -76,30 +82,22 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
}),
})
const methods = Effect.fn("ProviderAuthService.methods")(() =>
ScopedState.get(state).pipe(
Effect.map((x) =>
mapValues(x.methods, (y) =>
y.methods.map(
(z): Method => ({
type: z.type,
label: z.label,
}),
),
),
),
),
)
const methods = Effect.fn("ProviderAuthService.methods")(function* () {
const x = yield* InstanceState.get(state)
return Record.map(x.methods, (y) => y.methods.map((z): Method => Struct.pick(z, ["type", "label"])))
})
const authorize = Effect.fn("ProviderAuthService.authorize")(function* (input: {
providerID: ProviderID
method: number
}) {
const item = (yield* ScopedState.get(state)).methods[input.providerID]
const method = item.methods[input.method]
const authHook = (yield* InstanceState.get(state)).methods[input.providerID]
const method = authHook.methods[input.method]
if (method.type !== "oauth") return
const result = yield* Effect.promise(() => method.authorize())
;(yield* ScopedState.get(state)).pending[input.providerID] = result
const s = yield* InstanceState.get(state)
s.pending[input.providerID] = result
return {
url: result.url,
method: result.method,
@@ -112,17 +110,15 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
method: number
code?: string
}) {
const match = (yield* ScopedState.get(state)).pending[input.providerID]
const match = (yield* InstanceState.get(state)).pending[input.providerID]
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
const result =
match.method === "code"
? yield* Effect.gen(function* () {
const code = input.code
if (!code) return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
return yield* Effect.promise(() => match.callback(code))
})
: yield* Effect.promise(() => match.callback())
if (match.method === "code" && !input.code)
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
const result = yield* Effect.promise(() =>
match.method === "code" ? match.callback(input.code!) : match.callback(),
)
if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
@@ -0,0 +1,48 @@
import { Effect, ScopedCache, Scope } from "effect"
import { Instance } from "@/project/instance"
const TypeId = Symbol.for("@opencode/InstanceState")
type Task = (key: string) => Effect.Effect<void>
const tasks = new Set<Task>()
export interface InstanceState<A, E = never, R = never> {
readonly [TypeId]: typeof TypeId
readonly cache: ScopedCache.ScopedCache<string, A, E, R>
}
export const make = <A, E = never, R = never>(input: {
lookup: (key: string) => Effect.Effect<A, E, R>
release?: (value: A, key: string) => Effect.Effect<void>
}): Effect.Effect<InstanceState<A, E, R>, never, R | Scope.Scope> =>
Effect.gen(function* () {
const cache = yield* ScopedCache.make<string, A, E, R>({
capacity: Number.POSITIVE_INFINITY,
lookup: (key) =>
Effect.acquireRelease(input.lookup(key), (value) => (input.release ? input.release(value, key) : Effect.void)),
})
const task: Task = (key) => ScopedCache.invalidate(cache, key)
tasks.add(task)
yield* Effect.addFinalizer(() => Effect.sync(() => void tasks.delete(task)))
return {
[TypeId]: TypeId,
cache,
}
})
export const get = <A, E, R>(self: InstanceState<A, E, R>) => ScopedCache.get(self.cache, Instance.directory)
export const has = <A, E, R>(self: InstanceState<A, E, R>) => ScopedCache.has(self.cache, Instance.directory)
export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
ScopedCache.invalidate(self.cache, Instance.directory)
export const dispose = (key: string) =>
Effect.all(
[...tasks].map((task) => task(key)),
{ concurrency: "unbounded" },
)
@@ -1,39 +0,0 @@
import { Effect, ScopedCache, Scope } from "effect"
const TypeId = Symbol.for("@opencode/ScopedState")
export interface ScopedState<A, E = never, R = never> {
readonly [TypeId]: typeof TypeId
readonly root: () => string
readonly cache: ScopedCache.ScopedCache<string, A, E, R>
}
export const make = <A, E = never, R = never>(input: {
root: () => string
lookup: (key: string) => Effect.Effect<A, E, R>
release?: (value: A, key: string) => Effect.Effect<void>
}): Effect.Effect<ScopedState<A, E, R>, never, Scope.Scope | R> =>
ScopedCache.make<string, A, E, R>({
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 = <A, E, R>(self: ScopedState<A, E, R>) => ScopedCache.get(self.cache, self.root())
export const getAt = <A, E, R>(self: ScopedState<A, E, R>, key: string) => ScopedCache.get(self.cache, key)
export const invalidate = <A, E, R>(self: ScopedState<A, E, R>) => ScopedCache.invalidate(self.cache, self.root())
export const invalidateAt = <A, E, R>(self: ScopedState<A, E, R>, key: string) =>
ScopedCache.invalidate(self.cache, key)
export const has = <A, E, R>(self: ScopedState<A, E, R>) => ScopedCache.has(self.cache, self.root())
export const hasAt = <A, E, R>(self: ScopedState<A, E, R>, key: string) => ScopedCache.has(self.cache, key)