Skip to content
Closed
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
6 changes: 4 additions & 2 deletions packages/opencode/src/project/instance.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Effect } from "effect"
import { Log } from "@/util/log"
import { Context } from "../util/context"
import { Project } from "./project"
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
Expand Down Expand Up @@ -106,15 +108,15 @@ 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)
return await next
},
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)
},
Expand Down
80 changes: 38 additions & 42 deletions packages/opencode/src/provider/auth-service.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,14 @@
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 InstanceState from "@/util/instance-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<string, AuthOuathResult> }
})

export type Method = {
type: "oauth" | "api"
label: string
Expand Down Expand Up @@ -53,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>
}
}
Expand All @@ -71,35 +69,35 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
ProviderAuthService,
Effect.gen(function* () {
const auth = yield* Auth.AuthService
const state = yield* InstanceState.make({
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<string, AuthOuathResult> }
}),
})

const methods = Effect.fn("ProviderAuthService.methods")(() =>
Effect.promise(() =>
state().then((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* Effect.promise(() => state().then((x) => x.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* Effect.promise(() =>
state().then((x) => {
x.pending[input.providerID] = result
}),
)

const s = yield* InstanceState.get(state)
s.pending[input.providerID] = result
return {
url: result.url,
method: result.method,
Expand All @@ -112,17 +110,15 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
method: number
code?: string
}) {
const match = yield* Effect.promise(() => state().then((x) => x.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({}))

Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/provider/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<A>(f: (service: S.ProviderAuthService.Service) => Effect.Effect<A, S.ProviderAuthError>) {
Expand Down
48 changes: 48 additions & 0 deletions packages/opencode/src/util/instance-state.ts
Original file line number Diff line number Diff line change
@@ -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" },
)
87 changes: 87 additions & 0 deletions packages/opencode/test/project/state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { expect, test } from "bun:test"

import { State } from "../../src/project/state"

test("State.create caches values for the same key", async () => {
let key = "a"
let n = 0
const state = State.create(
() => key,
() => ({ n: ++n }),
)

const a = state()
const b = state()

expect(a).toBe(b)
expect(n).toBe(1)

await State.dispose("a")
})

test("State.create isolates values by key", async () => {
let key = "a"
let n = 0
const state = State.create(
() => key,
() => ({ n: ++n }),
)

const a = state()
key = "b"
const b = state()
key = "a"
const c = state()

expect(a).toBe(c)
expect(a).not.toBe(b)
expect(n).toBe(2)

await State.dispose("a")
await State.dispose("b")
})

test("State.dispose clears a key and runs cleanup", async () => {
const seen: string[] = []
let key = "a"
let n = 0
const state = State.create(
() => key,
() => ({ n: ++n }),
async (value) => {
seen.push(String(value.n))
},
)

const a = state()
await State.dispose("a")
const b = state()

expect(a).not.toBe(b)
expect(seen).toEqual(["1"])

await State.dispose("a")
})

test("State.create dedupes concurrent promise initialization", async () => {
const gate = Promise.withResolvers<void>()
let n = 0
const state = State.create(
() => "a",
async () => {
n += 1
await gate.promise
return { n }
},
)

const task = Promise.all([state(), state()])
await Promise.resolve()
expect(n).toBe(1)

gate.resolve()
const [a, b] = await task
expect(a).toBe(b)

await State.dispose("a")
})
Loading
Loading