From f7259617e5cf0784225c8a8919346ef5438de2ef Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 12 Mar 2026 13:05:50 -0400 Subject: [PATCH 01/14] refactor(auth): extract AuthService Move auth file I/O and key normalization into an Effect service so auth can migrate like account while the existing Auth facade stays stable for callers. Document the broader Effect rollout and instance-state migration strategy to guide follow-on extractions. --- packages/opencode/EFFECT_MIGRATION_PLAN.md | 399 +++++++++++++++++++++ packages/opencode/src/auth/index.ts | 84 ++--- packages/opencode/src/auth/service.ts | 114 ++++++ packages/opencode/src/effect/runtime.ts | 5 +- 4 files changed, 542 insertions(+), 60 deletions(-) create mode 100644 packages/opencode/EFFECT_MIGRATION_PLAN.md create mode 100644 packages/opencode/src/auth/service.ts diff --git a/packages/opencode/EFFECT_MIGRATION_PLAN.md b/packages/opencode/EFFECT_MIGRATION_PLAN.md new file mode 100644 index 00000000000..609b1954598 --- /dev/null +++ b/packages/opencode/EFFECT_MIGRATION_PLAN.md @@ -0,0 +1,399 @@ +# Effect migration + +Practical path for adopting Effect in opencode. + +## Aim + +Move `packages/opencode` toward Effect one domain at a time. Treat the migration as successful when the core path for a domain is Effect-based, even if temporary promise wrappers still exist at the edges. + +--- + +## Decide + +Use these defaults unless a domain gives us a good reason not to. + +- Migrate one module or domain at a time +- Preserve compatibility mainly at boundaries, not throughout internals +- Prefer adapter-layer-first work for mutable or request-scoped systems +- Treat CLI, server, and jobs as runtime boundaries +- Use the shared managed runtime only as a bridge during migration + +This keeps the work incremental and lets us remove compatibility code later instead of freezing it into every layer. + +--- + +## Slice work + +Pick migration units that can own a clear service boundary and a small runtime story. + +Good early candidates: + +- CRUD-like domains with stable storage and HTTP boundaries +- Modules that already have a natural service shape +- Areas where a promise facade can stay temporarily at the public edge + +Harder candidates: + +- `Instance`-like systems with async local state +- Request-scoped mutable state +- Modules that implicitly depend on ambient context or lifecycle ordering + +--- + +## Start at boundaries + +Begin by extracting an Effect service behind the existing module boundary. Keep old call sites working by adding a thin promise facade only where needed. + +Current example: + +- `packages/opencode/src/account/service.ts` holds the Effect-native service +- `packages/opencode/src/account/index.ts` keeps a promise-facing facade +- `packages/opencode/src/cli/cmd/account.ts` already uses `AccountService` directly +- `packages/opencode/src/config/config.ts` and `packages/opencode/src/share/share-next.ts` still use the facade + +This is the preferred first move for most domains. + +--- + +## Bridge runtime + +Use a shared app runtime only to help mixed code coexist while we migrate. Do not treat it as the final architecture by default. + +Current bridge: + +- `packages/opencode/src/effect/runtime.ts` + +Near-term rule: + +- Effect-native entrypoints can run effects directly +- Legacy promise namespaces can call into the shared runtime +- New domains should not depend on broad global runtime access unless they are explicitly boundary adapters + +As more boundaries become Effect-native, the shared runtime should shrink instead of becoming more central. + +--- + +## Handle state + +Treat async local state and mutable contextual systems as adapter problems first. Do not force `Instance`-style behavior directly into pure domain services on the first pass. + +Recommended approach: + +- Keep current mutable/contextual machinery behind a small adapter +- Expose a narrower Effect service above that adapter +- Move ambient reads and writes to the edge of the module +- Delay deeper context redesign until the boundary is stable + +For `Instance`-like code, the first win is usually isolating state access, not eliminating it. + +--- + +## Wrap `Instance` + +Keep `Instance` backed by AsyncLocalStorage for now. Do not force a full ALS replacement before we have a clearer service boundary. + +- Add an Effect-facing interface over the current ALS-backed implementation first +- Point new Effect code at that interface +- Let untouched legacy code keep using raw `Instance` + +We may split mutable state from read-only context as the design settles. If that happens, state can migrate on its own path and then depend on the Effect-facing context version instead of raw ALS directly. + +**Instance.state** - Most modules use `Instance.state()` for scoped mutable state, so we should not try to replace `Instance` itself too early. Start by wrapping it in an adapter and exposing an Effect service above the current machinery. Over time, state should move onto an Effectful abstraction of our own, with `ScopedCache` as the most likely fit for per-instance state that needs keyed lookup and cleanup. It can stay scoped by the current instance key during transition, usually the directory today, while domains can still add finer keys like `SessionID` inside their own state where needed. + +This keeps the first step small, lowers risk, and avoids redesigning request context too early. + +--- + +## Shape APIs + +Prefer an Effect-first core and a compatibility shell at the edge. + +Guidance: + +- Name the service after the domain, like `AccountService` +- Keep methods small and domain-shaped, not transport-shaped +- Return `Effect` from the core service +- Use promise helpers only in legacy namespaces or boundary adapters +- Keep error types explicit when the domain already has stable error shapes + +Small pattern: + +```ts +export class FooService extends ServiceMap.Service()("@opencode/Foo") { + static readonly layer = Layer.effect( + FooService, + Effect.gen(function* () { + return FooService.of({ + get: Effect.fn("FooService.get")(function* (id: FooID) { + return yield* ... + }), + }) + }), + ) +} +``` + +Temporary facade pattern: + +```ts +function runPromise(f: (service: FooService.Service) => Effect.Effect) { + return runtime.runPromise(FooService.use(f)) +} + +export namespace Foo { + export function get(id: FooID) { + return runPromise((service) => service.get(id)) + } +} +``` + +--- + +## Use Repo carefully + +A `Repo` layer is often useful, but it should stay a tool, not a rule. + +Tradeoffs: + +- `Repo` helps when storage concerns are real and reusable +- `Repo` can clarify error mapping and persistence boundaries +- `Repo` can also add ceremony for thin modules or one-step workflows + +Current leaning: + +- Use a `Repo` when it simplifies storage-heavy domains +- Skip it when a direct service implementation stays clearer +- Revisit consistency after a few more migrations, not before + +`packages/opencode/src/account/repo.ts` is a reasonable pattern for storage-backed domains, but it should not become mandatory yet. + +--- + +## Test safely + +Keep tests stable while internals move. Prefer preserving current test surfaces until a domain has fully crossed its main boundary. + +Practical guidance: + +- Keep existing promise-based tests passing first +- Add focused tests for new service behavior where it reduces risk +- Move boundary tests later, after the internal service shape settles +- Avoid rewriting test helpers and runtime wiring in the same PR as a domain extraction + +This lowers risk and makes the migration easier to review. + +--- + +## Roll out + +Use a phased roadmap. + +### Phase 0 + +Set conventions and prove the boundary pattern. + +- Keep `account` as the reference example, but not the template for every case +- Document the temporary runtime bridge and when to use it +- Prefer one or two more CRUD-like domains next + +### Phase 1 + +Migrate easy and medium domains one at a time. + +- Extract service +- Keep boundary facade if needed +- Convert one runtime entrypoint to direct Effect use +- Collapse internal promise plumbing inside the domain + +### Phase 2 + +Tackle context-heavy systems with adapters first. + +- Isolate async local state behind Effect-facing adapters +- Move lifecycle and mutable state reads to runtime edges +- Convert core domain logic before trying to redesign shared context + +### Phase 3 + +Reduce bridges and compatibility surfaces. + +- Remove facades that no longer serve external callers +- Narrow the shared runtime bridge +- Standardize remaining service and error shapes where it now feels earned + +--- + +## Check progress + +Use these signals to judge whether a domain is really migrated. + +A domain is in good shape when: + +- Its core logic runs through an Effect service +- Internal callers prefer the Effect API +- Compatibility wrappers exist only at real boundaries +- CLI, server, or job entrypoints can run the Effect path directly +- The shared runtime is only a temporary connector, not the center of the design + +A domain is not done just because it has an Effect service somewhere in the stack. + +--- + +## Candidate ranking + +Ranked by feasibility and payoff. Account is already migrated and serves as the reference. + +### Tier 1 — Easy wins + +| # | Module | Lines | Shape | Why | +| --- | -------------- | ----- | -------------------------------------- | -------------------------------------------------------------------------------------------------- | +| 1 | **Auth** | 74 | File CRUD (get/set/remove) | Zero ambient state, zero deps besides Filesystem. Trivial win to prove the pattern beyond account. | +| 2 | **Question** | 168 | ask/reply/reject + Instance.state Map | Clean service boundary, single pending Map. Nearly identical to Permission but simpler. | +| 3 | **Permission** | 210 | ask/respond/list + session-scoped Maps | Pending + approved Maps, already uses branded IDs. Session-scoped state maps to Effect context. | +| 4 | **Scheduler** | 62 | register/unregister tasks with timers | `Effect.repeat` / `Effect.schedule` is a natural fit. Tiny surface area. | + +### Tier 2 — Medium complexity, high payoff + +| # | Module | Lines | Shape | Why | +| --- | ---------------- | ----- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| 5 | **Pty** | 318 | Session lifecycle (create/remove/resize/write) | Process + subscriber cleanup maps to `Effect.acquireRelease`. Buffer/subscriber state is instance-scoped. | +| 6 | **Bus** | 106 | Pub/sub with instance-scoped subscriptions | Fiber-based subscription cleanup would eliminate manual `off()` patterns throughout the codebase. | +| 7 | **Snapshot** | 417 | Git snapshot/patch/restore | Heavy subprocess I/O. Effect error handling and retry would help. No ambient state. | +| 8 | **Worktree** | 673 | Git worktree create/remove/reset | Stateless, all subprocess-based. Good `Effect.fn` candidate but larger surface. | +| 9 | **Installation** | 304 | Version check + upgrade across package managers | Multiple fallback paths (npm/brew/choco/scoop). Effect's error channel shines here. | + +### Tier 3 — Harder, migrate after patterns are settled + +| # | Module | Lines | Shape | Why | +| --- | -------- | ----- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| 10 | **File** | 655 | File ops with cache state | Background fetch side-effects would benefit from Fiber management. Mutable cache (files/dirs Maps) adds complexity. | +| 11 | **LSP** | 487 | Client lifecycle: spawning → connected → broken | Effect resource management fits, but multi-state transitions are tricky. | +| 12 | **MCP** | 981 | Client lifecycle + OAuth flows | Largest single module. OAuth state spans multiple functions (startAuth → finishAuth). High payoff but highest risk. | + +### Avoid early + +These are too large, too foundational, or too pervasive to migrate without significant prior experience: + +- **Provider** (~1400 lines) — provider-specific branching, AI SDK abstractions, complex model selection +- **Session** (~900 lines) — complex relational queries, branching logic, many dependents +- **Config** — pervasive dependency across codebase, complex precedence rules +- **Project / Instance** — foundational bootstrap, async local state, everything depends on it + +### Patterns to watch for + +**Instance.state** — Most modules use `Instance.state()` for scoped mutable state. Don't try to replace Instance itself early; wrap it in an adapter that exposes an Effect service above the existing machinery. + +**Bus.subscribe + manual off()** — Pervasive throughout the codebase. Migrating Bus (candidate #6) unlocks Fiber-based cleanup everywhere, but it's infrastructure, not a domain win. Consider it after a few domain migrations prove the pattern. + +**Database.use / Database.transaction** — Already resembles Effect context (provide/use pattern). Could become an Effect Layer, but this is infrastructure work best deferred until multiple domains are Effect-native. + +**Process subprocess patterns** — Snapshot, Worktree, Installation all shell out to git or package managers. These are natural `Effect.tryPromise` / `Effect.fn` targets with error mapping. + +--- + +## Effect modules to use + +Effect already provides battle-tested replacements for several homegrown patterns. Prefer these over custom code as domains migrate. + +### PubSub → replaces Bus + +`PubSub` provides bounded/unbounded pub/sub with backpressure strategies. Subscriptions are scoped — cleanup is automatic when the subscriber's Scope closes, eliminating every manual `off()` call. + +```ts +const pubsub = yield * PubSub.unbounded() +yield * PubSub.publish(pubsub, event) +// subscriber — automatically cleaned up when scope ends +const dequeue = yield * PubSub.subscribe(pubsub) +const event = yield * Queue.take(dequeue) +``` + +Don't migrate Bus first. Migrate domain modules, then swap Bus once there are enough Effect-native consumers. + +### Schedule → replaces Scheduler + +The custom 62-line Scheduler reinvents `Effect.repeat`. Effect's `Schedule` is composable and supports spaced intervals, exponential backoff, cron expressions, and more. + +```ts +yield * effect.pipe(Effect.repeat(Schedule.spaced("30 seconds"))) +``` + +### SubscriptionRef → replaces state + Bus.publish on mutation + +Several modules follow the pattern: mutate `Instance.state`, then `Bus.publish` to notify listeners. `SubscriptionRef` is a `Ref` that emits changes as a `Stream`, combining both in one primitive. + +```ts +const ref = yield * SubscriptionRef.make(initialState) +// writer +yield * SubscriptionRef.update(ref, (s) => ({ ...s, count: s.count + 1 })) +// reader — stream of every state change +yield * SubscriptionRef.changes(ref).pipe(Stream.runForEach(handleUpdate)) +``` + +### Ref / SynchronizedRef → replaces Instance.state Maps + +`Ref` provides atomic read/write/update for concurrent-safe state. `SynchronizedRef` adds mutual exclusion for complex multi-step updates. Use these inside Effect services instead of raw mutable Maps. + +### Scope + acquireRelease → replaces manual resource cleanup + +Pty sessions, LSP clients, and MCP clients all have manual try/finally cleanup. `Effect.acquireRelease` ties resource lifecycle to Scope, making cleanup declarative and leak-proof. + +```ts +const pty = yield * Effect.acquireRelease(createPty(options), (session) => destroyPty(session)) +``` + +### ChildProcess → replaces shell-outs + +Effect's `ChildProcess` provides type-safe subprocess execution with template literals and stream-based stdout/stderr. Useful for Snapshot, Worktree, and Installation modules. + +```ts +const result = yield * ChildProcess.make`git diff --stat`.pipe(ChildProcess.spawn, ChildProcess.string) +``` + +Note: in `effect/unstable/process` — API may shift. + +### FileSystem → replaces custom Filesystem utils + +Cross-platform file I/O with stream support. Available via `effect/FileSystem` with a `NodeFileSystem` layer. + +### KeyValueStore → replaces file-based Auth JSON + +Abstracted key-value storage with file, memory, and browser backends. Auth's 74-line file CRUD could become a one-liner with `KeyValueStore`. + +Available via `effect/unstable/persistence` — API may shift. + +### HttpClient → replaces custom fetch calls + +Full HTTP client with typed errors, request builders, and platform-aware layers. Useful when migrating Share and ControlPlane modules. + +Available via `effect/unstable/http` — API may shift. + +### HttpApi → replaces Hono + +Effect's `HttpApi` provides schema-driven HTTP APIs with OpenAPI generation, type-safe routing, and middleware. Long-term candidate to replace the Hono server layer entirely. This is a larger lift — defer until multiple domain services are Effect-native and the boundary pattern is well-proven. + +Available via `effect/unstable/httpapi` — API may shift. + +### Schema → replaces Zod (partially) + +Effect's `Schema` provides encoding/decoding, validation, and type derivation deeply integrated with Effect. Internal code can migrate to Schema as domains move to Effect services. However, the plugin API (`@opencode-ai/plugin`) uses Zod and must continue to accept Zod schemas at the boundary. Keep Zod-to-Schema bridges at plugin/SDK edges. + +### Cache → replaces manual caching + +The File module maintains mutable Maps (files/dirs) with a fetching flag for deduplication. `Cache` provides memoization with TTL and automatic deduplication, replacing this pattern. + +### Pool → for resource-heavy clients + +LSP client management (spawning/connected/broken state machine) could benefit from `Pool` for automatic acquisition, health checking, and release. + +--- + +## Follow next + +Recommended medium-term order: + +1. Continue with CRUD-like or storage-backed modules +2. Convert boundary entrypoints in CLI, server, and jobs as services become available +3. Move into `Instance`-adjacent systems with adapter layers, not direct rewrites +4. Remove promise facades after direct callers have moved + +This keeps momentum while reserving the hardest context work for when the team has a clearer house style. diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 80253a665e9..d362069f206 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,73 +1,41 @@ -import path from "path" -import { Global } from "../global" -import z from "zod" -import { Filesystem } from "../util/filesystem" - -export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" +import { Effect } from "effect" +import { runtime } from "@/effect/runtime" +import { + Api as ApiSchema, + AuthService, + type AuthServiceError, + Info as InfoSchema, + Oauth as OauthSchema, + WellKnown as WellKnownSchema, + type Info as AuthInfo, +} from "./service" + +export { OAUTH_DUMMY_KEY } from "./service" + +function runPromise(f: (service: AuthService.Service) => Effect.Effect) { + return runtime.runPromise(AuthService.use(f)) +} export namespace Auth { - export const Oauth = z - .object({ - type: z.literal("oauth"), - refresh: z.string(), - access: z.string(), - expires: z.number(), - accountId: z.string().optional(), - enterpriseUrl: z.string().optional(), - }) - .meta({ ref: "OAuth" }) - - export const Api = z - .object({ - type: z.literal("api"), - key: z.string(), - }) - .meta({ ref: "ApiAuth" }) - - export const WellKnown = z - .object({ - type: z.literal("wellknown"), - key: z.string(), - token: z.string(), - }) - .meta({ ref: "WellKnownAuth" }) - - export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" }) - export type Info = z.infer - - const filepath = path.join(Global.Path.data, "auth.json") + export const Oauth = OauthSchema + export const Api = ApiSchema + export const WellKnown = WellKnownSchema + export const Info = InfoSchema + export type Info = AuthInfo export async function get(providerID: string) { - const auth = await all() - return auth[providerID] + return runPromise((service) => service.get(providerID)) } export async function all(): Promise> { - const data = await Filesystem.readJson>(filepath).catch(() => ({})) - return Object.entries(data).reduce( - (acc, [key, value]) => { - const parsed = Info.safeParse(value) - if (!parsed.success) return acc - acc[key] = parsed.data - return acc - }, - {} as Record, - ) + return runPromise((service) => service.all()) } export async function set(key: string, info: Info) { - const normalized = key.replace(/\/+$/, "") - const data = await all() - if (normalized !== key) delete data[key] - delete data[normalized + "/"] - await Filesystem.writeJson(filepath, { ...data, [normalized]: info }, 0o600) + return runPromise((service) => service.set(key, info)) } export async function remove(key: string) { - const normalized = key.replace(/\/+$/, "") - const data = await all() - delete data[key] - delete data[normalized] - await Filesystem.writeJson(filepath, data, 0o600) + return runPromise((service) => service.remove(key)) } } diff --git a/packages/opencode/src/auth/service.ts b/packages/opencode/src/auth/service.ts new file mode 100644 index 00000000000..c98087934c9 --- /dev/null +++ b/packages/opencode/src/auth/service.ts @@ -0,0 +1,114 @@ +import path from "path" +import { Effect, Layer, Schema, ServiceMap } from "effect" +import z from "zod" +import { Global } from "../global" +import { Filesystem } from "../util/filesystem" + +export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" + +export const Oauth = z + .object({ + type: z.literal("oauth"), + refresh: z.string(), + access: z.string(), + expires: z.number(), + accountId: z.string().optional(), + enterpriseUrl: z.string().optional(), + }) + .meta({ ref: "OAuth" }) + +export const Api = z + .object({ + type: z.literal("api"), + key: z.string(), + }) + .meta({ ref: "ApiAuth" }) + +export const WellKnown = z + .object({ + type: z.literal("wellknown"), + key: z.string(), + token: z.string(), + }) + .meta({ ref: "WellKnownAuth" }) + +export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" }) +export type Info = z.infer + +export class AuthServiceError extends Schema.TaggedErrorClass()("AuthServiceError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +const file = path.join(Global.Path.data, "auth.json") + +const fail = (message: string) => (cause: unknown) => new AuthServiceError({ message, cause }) + +export namespace AuthService { + export interface Service { + readonly get: (providerID: string) => Effect.Effect + readonly all: () => Effect.Effect, AuthServiceError> + readonly set: (key: string, info: Info) => Effect.Effect + readonly remove: (key: string) => Effect.Effect + } +} + +export class AuthService extends ServiceMap.Service()("@opencode/Auth") { + static readonly layer = Layer.effect( + AuthService, + Effect.gen(function* () { + const all = Effect.fn("AuthService.all")(() => + Effect.tryPromise({ + try: async () => { + const data = await Filesystem.readJson>(file).catch(() => ({})) + return Object.entries(data).reduce( + (acc, [key, value]) => { + const parsed = Info.safeParse(value) + if (!parsed.success) return acc + acc[key] = parsed.data + return acc + }, + {} as Record, + ) + }, + catch: fail("Failed to read auth data"), + }), + ) + + const get = Effect.fn("AuthService.get")(function* (providerID: string) { + return (yield* all())[providerID] + }) + + const set = Effect.fn("AuthService.set")(function* (key: string, info: Info) { + const norm = key.replace(/\/+$/, "") + const data = yield* all() + if (norm !== key) delete data[key] + delete data[norm + "/"] + yield* Effect.tryPromise({ + try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600), + catch: fail("Failed to write auth data"), + }) + }) + + const remove = Effect.fn("AuthService.remove")(function* (key: string) { + const norm = key.replace(/\/+$/, "") + const data = yield* all() + delete data[key] + delete data[norm] + yield* Effect.tryPromise({ + try: () => Filesystem.writeJson(file, data, 0o600), + catch: fail("Failed to write auth data"), + }) + }) + + return AuthService.of({ + get, + all, + set, + remove, + }) + }), + ) + + static readonly defaultLayer = AuthService.layer +} diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts index 1868b38a015..23acff73379 100644 --- a/packages/opencode/src/effect/runtime.ts +++ b/packages/opencode/src/effect/runtime.ts @@ -1,4 +1,5 @@ -import { ManagedRuntime } from "effect" +import { Layer, ManagedRuntime } from "effect" import { AccountService } from "@/account/service" +import { AuthService } from "@/auth/service" -export const runtime = ManagedRuntime.make(AccountService.defaultLayer) +export const runtime = ManagedRuntime.make(Layer.mergeAll(AccountService.defaultLayer, AuthService.defaultLayer)) From 7f12976ea0f5b9b5c0ca471cd98851f5882a4b32 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 12 Mar 2026 13:25:52 -0400 Subject: [PATCH 02/14] refactor(auth): use Effect Schema internally Model auth entries with Effect Schema inside AuthService and use Schema decoding when reading persisted auth data. Keep the Auth facade on Zod at the boundary so existing validators and callers stay stable during the migration. --- packages/opencode/src/auth/index.ts | 48 ++++++++++++++------- packages/opencode/src/auth/service.ts | 61 ++++++++++++--------------- 2 files changed, 60 insertions(+), 49 deletions(-) diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index d362069f206..79e9e615d21 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,27 +1,43 @@ import { Effect } from "effect" +import z from "zod" import { runtime } from "@/effect/runtime" -import { - Api as ApiSchema, - AuthService, - type AuthServiceError, - Info as InfoSchema, - Oauth as OauthSchema, - WellKnown as WellKnownSchema, - type Info as AuthInfo, -} from "./service" +import * as S from "./service" export { OAUTH_DUMMY_KEY } from "./service" -function runPromise(f: (service: AuthService.Service) => Effect.Effect) { - return runtime.runPromise(AuthService.use(f)) +function runPromise(f: (service: S.AuthService.Service) => Effect.Effect) { + return runtime.runPromise(S.AuthService.use(f)) } export namespace Auth { - export const Oauth = OauthSchema - export const Api = ApiSchema - export const WellKnown = WellKnownSchema - export const Info = InfoSchema - export type Info = AuthInfo + export const Oauth = z + .object({ + type: z.literal("oauth"), + refresh: z.string(), + access: z.string(), + expires: z.number(), + accountId: z.string().optional(), + enterpriseUrl: z.string().optional(), + }) + .meta({ ref: "OAuth" }) + + export const Api = z + .object({ + type: z.literal("api"), + key: z.string(), + }) + .meta({ ref: "ApiAuth" }) + + export const WellKnown = z + .object({ + type: z.literal("wellknown"), + key: z.string(), + token: z.string(), + }) + .meta({ ref: "WellKnownAuth" }) + + export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" }) + export type Info = z.infer export async function get(providerID: string) { return runPromise((service) => service.get(providerID)) diff --git a/packages/opencode/src/auth/service.ts b/packages/opencode/src/auth/service.ts index c98087934c9..0898b39df7d 100644 --- a/packages/opencode/src/auth/service.ts +++ b/packages/opencode/src/auth/service.ts @@ -1,39 +1,32 @@ import path from "path" -import { Effect, Layer, Schema, ServiceMap } from "effect" -import z from "zod" +import { Effect, Layer, Option, Schema, ServiceMap } from "effect" import { Global } from "../global" import { Filesystem } from "../util/filesystem" export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" -export const Oauth = z - .object({ - type: z.literal("oauth"), - refresh: z.string(), - access: z.string(), - expires: z.number(), - accountId: z.string().optional(), - enterpriseUrl: z.string().optional(), - }) - .meta({ ref: "OAuth" }) - -export const Api = z - .object({ - type: z.literal("api"), - key: z.string(), - }) - .meta({ ref: "ApiAuth" }) - -export const WellKnown = z - .object({ - type: z.literal("wellknown"), - key: z.string(), - token: z.string(), - }) - .meta({ ref: "WellKnownAuth" }) - -export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" }) -export type Info = z.infer +export class Oauth extends Schema.Class("OAuth")({ + type: Schema.Literal("oauth"), + refresh: Schema.String, + access: Schema.String, + expires: Schema.Number, + accountId: Schema.optional(Schema.String), + enterpriseUrl: Schema.optional(Schema.String), +}) {} + +export class Api extends Schema.Class("ApiAuth")({ + type: Schema.Literal("api"), + key: Schema.String, +}) {} + +export class WellKnown extends Schema.Class("WellKnownAuth")({ + type: Schema.Literal("wellknown"), + key: Schema.String, + token: Schema.String, +}) {} + +export const Info = Schema.Union([Oauth, Api, WellKnown]) +export type Info = Schema.Schema.Type export class AuthServiceError extends Schema.TaggedErrorClass()("AuthServiceError", { message: Schema.String, @@ -57,15 +50,17 @@ export class AuthService extends ServiceMap.Service Effect.tryPromise({ try: async () => { const data = await Filesystem.readJson>(file).catch(() => ({})) return Object.entries(data).reduce( (acc, [key, value]) => { - const parsed = Info.safeParse(value) - if (!parsed.success) return acc - acc[key] = parsed.data + const parsed = decode(value) + if (Option.isNone(parsed)) return acc + acc[key] = parsed.value return acc }, {} as Record, From 201e80956a8adf671794c8b8ef7604dbcfb50871 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 12 Mar 2026 14:23:27 -0400 Subject: [PATCH 03/14] refactor(auth): clarify auth entry filtering Use Effect Record.filterMap to keep the existing permissive auth-file semantics while making the decode path easier to read. Add service method docs that explain key normalization and why old trailing-slash variants are removed during writes. --- packages/opencode/src/auth/service.ts | 39 ++++++++++++++++++++------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/auth/service.ts b/packages/opencode/src/auth/service.ts index 0898b39df7d..66d907dfb2a 100644 --- a/packages/opencode/src/auth/service.ts +++ b/packages/opencode/src/auth/service.ts @@ -1,5 +1,5 @@ import path from "path" -import { Effect, Layer, Option, Schema, ServiceMap } from "effect" +import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect" import { Global } from "../global" import { Filesystem } from "../util/filesystem" @@ -39,9 +39,36 @@ const fail = (message: string) => (cause: unknown) => new AuthServiceError({ mes export namespace AuthService { export interface Service { + /** + * Loads the auth entry stored under the given key. + * + * Keys are usually provider IDs, but some callers store URL-shaped keys. + */ readonly get: (providerID: string) => Effect.Effect + + /** + * Loads all persisted auth entries. + * + * Invalid entries are ignored instead of failing the whole file so older or + * partially-corrupt auth records do not break unrelated providers. + */ readonly all: () => Effect.Effect, AuthServiceError> + + /** + * Stores an auth entry under a normalized key. + * + * URL-shaped keys are normalized by trimming trailing slashes. Before + * writing, we delete both the original key and the normalized-with-slash + * variant so historical duplicates collapse to one canonical entry. + */ readonly set: (key: string, info: Info) => Effect.Effect + + /** + * Removes the auth entry stored under the provided key. + * + * The raw key and its normalized form are both deleted so callers can pass + * either version during cleanup. + */ readonly remove: (key: string) => Effect.Effect } } @@ -56,15 +83,7 @@ export class AuthService extends ServiceMap.Service { const data = await Filesystem.readJson>(file).catch(() => ({})) - return Object.entries(data).reduce( - (acc, [key, value]) => { - const parsed = decode(value) - if (Option.isNone(parsed)) return acc - acc[key] = parsed.value - return acc - }, - {} as Record, - ) + return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined)) }, catch: fail("Failed to read auth data"), }), From 11e2c85336cd79a8764b8996268469c559e6f899 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 12 Mar 2026 14:34:29 -0400 Subject: [PATCH 04/14] chore: keep effect migration plan local Remove the draft migration plan from the auth foundation branch and keep it excluded locally instead of shipping it in the PR. --- packages/opencode/EFFECT_MIGRATION_PLAN.md | 399 --------------------- 1 file changed, 399 deletions(-) delete mode 100644 packages/opencode/EFFECT_MIGRATION_PLAN.md diff --git a/packages/opencode/EFFECT_MIGRATION_PLAN.md b/packages/opencode/EFFECT_MIGRATION_PLAN.md deleted file mode 100644 index 609b1954598..00000000000 --- a/packages/opencode/EFFECT_MIGRATION_PLAN.md +++ /dev/null @@ -1,399 +0,0 @@ -# Effect migration - -Practical path for adopting Effect in opencode. - -## Aim - -Move `packages/opencode` toward Effect one domain at a time. Treat the migration as successful when the core path for a domain is Effect-based, even if temporary promise wrappers still exist at the edges. - ---- - -## Decide - -Use these defaults unless a domain gives us a good reason not to. - -- Migrate one module or domain at a time -- Preserve compatibility mainly at boundaries, not throughout internals -- Prefer adapter-layer-first work for mutable or request-scoped systems -- Treat CLI, server, and jobs as runtime boundaries -- Use the shared managed runtime only as a bridge during migration - -This keeps the work incremental and lets us remove compatibility code later instead of freezing it into every layer. - ---- - -## Slice work - -Pick migration units that can own a clear service boundary and a small runtime story. - -Good early candidates: - -- CRUD-like domains with stable storage and HTTP boundaries -- Modules that already have a natural service shape -- Areas where a promise facade can stay temporarily at the public edge - -Harder candidates: - -- `Instance`-like systems with async local state -- Request-scoped mutable state -- Modules that implicitly depend on ambient context or lifecycle ordering - ---- - -## Start at boundaries - -Begin by extracting an Effect service behind the existing module boundary. Keep old call sites working by adding a thin promise facade only where needed. - -Current example: - -- `packages/opencode/src/account/service.ts` holds the Effect-native service -- `packages/opencode/src/account/index.ts` keeps a promise-facing facade -- `packages/opencode/src/cli/cmd/account.ts` already uses `AccountService` directly -- `packages/opencode/src/config/config.ts` and `packages/opencode/src/share/share-next.ts` still use the facade - -This is the preferred first move for most domains. - ---- - -## Bridge runtime - -Use a shared app runtime only to help mixed code coexist while we migrate. Do not treat it as the final architecture by default. - -Current bridge: - -- `packages/opencode/src/effect/runtime.ts` - -Near-term rule: - -- Effect-native entrypoints can run effects directly -- Legacy promise namespaces can call into the shared runtime -- New domains should not depend on broad global runtime access unless they are explicitly boundary adapters - -As more boundaries become Effect-native, the shared runtime should shrink instead of becoming more central. - ---- - -## Handle state - -Treat async local state and mutable contextual systems as adapter problems first. Do not force `Instance`-style behavior directly into pure domain services on the first pass. - -Recommended approach: - -- Keep current mutable/contextual machinery behind a small adapter -- Expose a narrower Effect service above that adapter -- Move ambient reads and writes to the edge of the module -- Delay deeper context redesign until the boundary is stable - -For `Instance`-like code, the first win is usually isolating state access, not eliminating it. - ---- - -## Wrap `Instance` - -Keep `Instance` backed by AsyncLocalStorage for now. Do not force a full ALS replacement before we have a clearer service boundary. - -- Add an Effect-facing interface over the current ALS-backed implementation first -- Point new Effect code at that interface -- Let untouched legacy code keep using raw `Instance` - -We may split mutable state from read-only context as the design settles. If that happens, state can migrate on its own path and then depend on the Effect-facing context version instead of raw ALS directly. - -**Instance.state** - Most modules use `Instance.state()` for scoped mutable state, so we should not try to replace `Instance` itself too early. Start by wrapping it in an adapter and exposing an Effect service above the current machinery. Over time, state should move onto an Effectful abstraction of our own, with `ScopedCache` as the most likely fit for per-instance state that needs keyed lookup and cleanup. It can stay scoped by the current instance key during transition, usually the directory today, while domains can still add finer keys like `SessionID` inside their own state where needed. - -This keeps the first step small, lowers risk, and avoids redesigning request context too early. - ---- - -## Shape APIs - -Prefer an Effect-first core and a compatibility shell at the edge. - -Guidance: - -- Name the service after the domain, like `AccountService` -- Keep methods small and domain-shaped, not transport-shaped -- Return `Effect` from the core service -- Use promise helpers only in legacy namespaces or boundary adapters -- Keep error types explicit when the domain already has stable error shapes - -Small pattern: - -```ts -export class FooService extends ServiceMap.Service()("@opencode/Foo") { - static readonly layer = Layer.effect( - FooService, - Effect.gen(function* () { - return FooService.of({ - get: Effect.fn("FooService.get")(function* (id: FooID) { - return yield* ... - }), - }) - }), - ) -} -``` - -Temporary facade pattern: - -```ts -function runPromise(f: (service: FooService.Service) => Effect.Effect) { - return runtime.runPromise(FooService.use(f)) -} - -export namespace Foo { - export function get(id: FooID) { - return runPromise((service) => service.get(id)) - } -} -``` - ---- - -## Use Repo carefully - -A `Repo` layer is often useful, but it should stay a tool, not a rule. - -Tradeoffs: - -- `Repo` helps when storage concerns are real and reusable -- `Repo` can clarify error mapping and persistence boundaries -- `Repo` can also add ceremony for thin modules or one-step workflows - -Current leaning: - -- Use a `Repo` when it simplifies storage-heavy domains -- Skip it when a direct service implementation stays clearer -- Revisit consistency after a few more migrations, not before - -`packages/opencode/src/account/repo.ts` is a reasonable pattern for storage-backed domains, but it should not become mandatory yet. - ---- - -## Test safely - -Keep tests stable while internals move. Prefer preserving current test surfaces until a domain has fully crossed its main boundary. - -Practical guidance: - -- Keep existing promise-based tests passing first -- Add focused tests for new service behavior where it reduces risk -- Move boundary tests later, after the internal service shape settles -- Avoid rewriting test helpers and runtime wiring in the same PR as a domain extraction - -This lowers risk and makes the migration easier to review. - ---- - -## Roll out - -Use a phased roadmap. - -### Phase 0 - -Set conventions and prove the boundary pattern. - -- Keep `account` as the reference example, but not the template for every case -- Document the temporary runtime bridge and when to use it -- Prefer one or two more CRUD-like domains next - -### Phase 1 - -Migrate easy and medium domains one at a time. - -- Extract service -- Keep boundary facade if needed -- Convert one runtime entrypoint to direct Effect use -- Collapse internal promise plumbing inside the domain - -### Phase 2 - -Tackle context-heavy systems with adapters first. - -- Isolate async local state behind Effect-facing adapters -- Move lifecycle and mutable state reads to runtime edges -- Convert core domain logic before trying to redesign shared context - -### Phase 3 - -Reduce bridges and compatibility surfaces. - -- Remove facades that no longer serve external callers -- Narrow the shared runtime bridge -- Standardize remaining service and error shapes where it now feels earned - ---- - -## Check progress - -Use these signals to judge whether a domain is really migrated. - -A domain is in good shape when: - -- Its core logic runs through an Effect service -- Internal callers prefer the Effect API -- Compatibility wrappers exist only at real boundaries -- CLI, server, or job entrypoints can run the Effect path directly -- The shared runtime is only a temporary connector, not the center of the design - -A domain is not done just because it has an Effect service somewhere in the stack. - ---- - -## Candidate ranking - -Ranked by feasibility and payoff. Account is already migrated and serves as the reference. - -### Tier 1 — Easy wins - -| # | Module | Lines | Shape | Why | -| --- | -------------- | ----- | -------------------------------------- | -------------------------------------------------------------------------------------------------- | -| 1 | **Auth** | 74 | File CRUD (get/set/remove) | Zero ambient state, zero deps besides Filesystem. Trivial win to prove the pattern beyond account. | -| 2 | **Question** | 168 | ask/reply/reject + Instance.state Map | Clean service boundary, single pending Map. Nearly identical to Permission but simpler. | -| 3 | **Permission** | 210 | ask/respond/list + session-scoped Maps | Pending + approved Maps, already uses branded IDs. Session-scoped state maps to Effect context. | -| 4 | **Scheduler** | 62 | register/unregister tasks with timers | `Effect.repeat` / `Effect.schedule` is a natural fit. Tiny surface area. | - -### Tier 2 — Medium complexity, high payoff - -| # | Module | Lines | Shape | Why | -| --- | ---------------- | ----- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------- | -| 5 | **Pty** | 318 | Session lifecycle (create/remove/resize/write) | Process + subscriber cleanup maps to `Effect.acquireRelease`. Buffer/subscriber state is instance-scoped. | -| 6 | **Bus** | 106 | Pub/sub with instance-scoped subscriptions | Fiber-based subscription cleanup would eliminate manual `off()` patterns throughout the codebase. | -| 7 | **Snapshot** | 417 | Git snapshot/patch/restore | Heavy subprocess I/O. Effect error handling and retry would help. No ambient state. | -| 8 | **Worktree** | 673 | Git worktree create/remove/reset | Stateless, all subprocess-based. Good `Effect.fn` candidate but larger surface. | -| 9 | **Installation** | 304 | Version check + upgrade across package managers | Multiple fallback paths (npm/brew/choco/scoop). Effect's error channel shines here. | - -### Tier 3 — Harder, migrate after patterns are settled - -| # | Module | Lines | Shape | Why | -| --- | -------- | ----- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -| 10 | **File** | 655 | File ops with cache state | Background fetch side-effects would benefit from Fiber management. Mutable cache (files/dirs Maps) adds complexity. | -| 11 | **LSP** | 487 | Client lifecycle: spawning → connected → broken | Effect resource management fits, but multi-state transitions are tricky. | -| 12 | **MCP** | 981 | Client lifecycle + OAuth flows | Largest single module. OAuth state spans multiple functions (startAuth → finishAuth). High payoff but highest risk. | - -### Avoid early - -These are too large, too foundational, or too pervasive to migrate without significant prior experience: - -- **Provider** (~1400 lines) — provider-specific branching, AI SDK abstractions, complex model selection -- **Session** (~900 lines) — complex relational queries, branching logic, many dependents -- **Config** — pervasive dependency across codebase, complex precedence rules -- **Project / Instance** — foundational bootstrap, async local state, everything depends on it - -### Patterns to watch for - -**Instance.state** — Most modules use `Instance.state()` for scoped mutable state. Don't try to replace Instance itself early; wrap it in an adapter that exposes an Effect service above the existing machinery. - -**Bus.subscribe + manual off()** — Pervasive throughout the codebase. Migrating Bus (candidate #6) unlocks Fiber-based cleanup everywhere, but it's infrastructure, not a domain win. Consider it after a few domain migrations prove the pattern. - -**Database.use / Database.transaction** — Already resembles Effect context (provide/use pattern). Could become an Effect Layer, but this is infrastructure work best deferred until multiple domains are Effect-native. - -**Process subprocess patterns** — Snapshot, Worktree, Installation all shell out to git or package managers. These are natural `Effect.tryPromise` / `Effect.fn` targets with error mapping. - ---- - -## Effect modules to use - -Effect already provides battle-tested replacements for several homegrown patterns. Prefer these over custom code as domains migrate. - -### PubSub → replaces Bus - -`PubSub` provides bounded/unbounded pub/sub with backpressure strategies. Subscriptions are scoped — cleanup is automatic when the subscriber's Scope closes, eliminating every manual `off()` call. - -```ts -const pubsub = yield * PubSub.unbounded() -yield * PubSub.publish(pubsub, event) -// subscriber — automatically cleaned up when scope ends -const dequeue = yield * PubSub.subscribe(pubsub) -const event = yield * Queue.take(dequeue) -``` - -Don't migrate Bus first. Migrate domain modules, then swap Bus once there are enough Effect-native consumers. - -### Schedule → replaces Scheduler - -The custom 62-line Scheduler reinvents `Effect.repeat`. Effect's `Schedule` is composable and supports spaced intervals, exponential backoff, cron expressions, and more. - -```ts -yield * effect.pipe(Effect.repeat(Schedule.spaced("30 seconds"))) -``` - -### SubscriptionRef → replaces state + Bus.publish on mutation - -Several modules follow the pattern: mutate `Instance.state`, then `Bus.publish` to notify listeners. `SubscriptionRef` is a `Ref` that emits changes as a `Stream`, combining both in one primitive. - -```ts -const ref = yield * SubscriptionRef.make(initialState) -// writer -yield * SubscriptionRef.update(ref, (s) => ({ ...s, count: s.count + 1 })) -// reader — stream of every state change -yield * SubscriptionRef.changes(ref).pipe(Stream.runForEach(handleUpdate)) -``` - -### Ref / SynchronizedRef → replaces Instance.state Maps - -`Ref` provides atomic read/write/update for concurrent-safe state. `SynchronizedRef` adds mutual exclusion for complex multi-step updates. Use these inside Effect services instead of raw mutable Maps. - -### Scope + acquireRelease → replaces manual resource cleanup - -Pty sessions, LSP clients, and MCP clients all have manual try/finally cleanup. `Effect.acquireRelease` ties resource lifecycle to Scope, making cleanup declarative and leak-proof. - -```ts -const pty = yield * Effect.acquireRelease(createPty(options), (session) => destroyPty(session)) -``` - -### ChildProcess → replaces shell-outs - -Effect's `ChildProcess` provides type-safe subprocess execution with template literals and stream-based stdout/stderr. Useful for Snapshot, Worktree, and Installation modules. - -```ts -const result = yield * ChildProcess.make`git diff --stat`.pipe(ChildProcess.spawn, ChildProcess.string) -``` - -Note: in `effect/unstable/process` — API may shift. - -### FileSystem → replaces custom Filesystem utils - -Cross-platform file I/O with stream support. Available via `effect/FileSystem` with a `NodeFileSystem` layer. - -### KeyValueStore → replaces file-based Auth JSON - -Abstracted key-value storage with file, memory, and browser backends. Auth's 74-line file CRUD could become a one-liner with `KeyValueStore`. - -Available via `effect/unstable/persistence` — API may shift. - -### HttpClient → replaces custom fetch calls - -Full HTTP client with typed errors, request builders, and platform-aware layers. Useful when migrating Share and ControlPlane modules. - -Available via `effect/unstable/http` — API may shift. - -### HttpApi → replaces Hono - -Effect's `HttpApi` provides schema-driven HTTP APIs with OpenAPI generation, type-safe routing, and middleware. Long-term candidate to replace the Hono server layer entirely. This is a larger lift — defer until multiple domain services are Effect-native and the boundary pattern is well-proven. - -Available via `effect/unstable/httpapi` — API may shift. - -### Schema → replaces Zod (partially) - -Effect's `Schema` provides encoding/decoding, validation, and type derivation deeply integrated with Effect. Internal code can migrate to Schema as domains move to Effect services. However, the plugin API (`@opencode-ai/plugin`) uses Zod and must continue to accept Zod schemas at the boundary. Keep Zod-to-Schema bridges at plugin/SDK edges. - -### Cache → replaces manual caching - -The File module maintains mutable Maps (files/dirs) with a fetching flag for deduplication. `Cache` provides memoization with TTL and automatic deduplication, replacing this pattern. - -### Pool → for resource-heavy clients - -LSP client management (spawning/connected/broken state machine) could benefit from `Pool` for automatic acquisition, health checking, and release. - ---- - -## Follow next - -Recommended medium-term order: - -1. Continue with CRUD-like or storage-backed modules -2. Convert boundary entrypoints in CLI, server, and jobs as services become available -3. Move into `Instance`-adjacent systems with adapter layers, not direct rewrites -4. Remove promise facades after direct callers have moved - -This keeps momentum while reserving the hardest context work for when the team has a clearer house style. From 1739817ee7908ad5ac929e10fce839d76ac113f7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 12 Mar 2026 14:38:44 -0400 Subject: [PATCH 05/14] style(auth): remove service docstrings Drop the temporary auth service method comments now that the key normalization behavior has been reviewed. --- packages/opencode/src/auth/service.ts | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/packages/opencode/src/auth/service.ts b/packages/opencode/src/auth/service.ts index 66d907dfb2a..100a132b873 100644 --- a/packages/opencode/src/auth/service.ts +++ b/packages/opencode/src/auth/service.ts @@ -39,36 +39,9 @@ const fail = (message: string) => (cause: unknown) => new AuthServiceError({ mes export namespace AuthService { export interface Service { - /** - * Loads the auth entry stored under the given key. - * - * Keys are usually provider IDs, but some callers store URL-shaped keys. - */ readonly get: (providerID: string) => Effect.Effect - - /** - * Loads all persisted auth entries. - * - * Invalid entries are ignored instead of failing the whole file so older or - * partially-corrupt auth records do not break unrelated providers. - */ readonly all: () => Effect.Effect, AuthServiceError> - - /** - * Stores an auth entry under a normalized key. - * - * URL-shaped keys are normalized by trimming trailing slashes. Before - * writing, we delete both the original key and the normalized-with-slash - * variant so historical duplicates collapse to one canonical entry. - */ readonly set: (key: string, info: Info) => Effect.Effect - - /** - * Removes the auth entry stored under the provided key. - * - * The raw key and its normalized form are both deleted so callers can pass - * either version during cleanup. - */ readonly remove: (key: string) => Effect.Effect } } From f96f235dcfa824265c55da7ac98719b16d0cf94c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 12 Mar 2026 14:43:09 -0400 Subject: [PATCH 06/14] refactor(provider): use AuthService in auth flows Point ProviderAuth persistence at AuthService instead of going back through the legacy Auth facade. Add a focused test that exercises the provider auth API path and confirms credentials still persist correctly. --- packages/opencode/src/provider/auth.ts | 12 +++++++++--- packages/opencode/test/provider/auth.test.ts | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/test/provider/auth.test.ts diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index f6c25432034..29e519e048f 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -1,4 +1,6 @@ import { Instance } from "@/project/instance" +import { runtime } from "@/effect/runtime" +import { AuthService } from "@/auth/service" import { Plugin } from "../plugin" import { map, filter, pipe, fromEntries, mapValues } from "remeda" import z from "zod" @@ -9,6 +11,10 @@ import { Auth } from "@/auth" import { ProviderID } from "./schema" export namespace ProviderAuth { + function set(key: string, info: Auth.Info) { + return runtime.runPromise(AuthService.use((service) => service.set(key, info))) + } + const state = Instance.state(async () => { const methods = pipe( await Plugin.list(), @@ -94,7 +100,7 @@ export namespace ProviderAuth { if (result?.type === "success") { if ("key" in result) { - await Auth.set(input.providerID, { + await set(input.providerID, { type: "api", key: result.key, }) @@ -109,7 +115,7 @@ export namespace ProviderAuth { if (result.accountId) { info.accountId = result.accountId } - await Auth.set(input.providerID, info) + await set(input.providerID, info) } return } @@ -124,7 +130,7 @@ export namespace ProviderAuth { key: z.string(), }), async (input) => { - await Auth.set(input.providerID, { + await set(input.providerID, { type: "api", key: input.key, }) diff --git a/packages/opencode/test/provider/auth.test.ts b/packages/opencode/test/provider/auth.test.ts new file mode 100644 index 00000000000..99babd44a69 --- /dev/null +++ b/packages/opencode/test/provider/auth.test.ts @@ -0,0 +1,20 @@ +import { afterEach, expect, test } from "bun:test" +import { Auth } from "../../src/auth" +import { ProviderAuth } from "../../src/provider/auth" +import { ProviderID } from "../../src/provider/schema" + +afterEach(async () => { + await Auth.remove("test-provider-auth") +}) + +test("ProviderAuth.api persists auth via AuthService", async () => { + await ProviderAuth.api({ + providerID: ProviderID.make("test-provider-auth"), + key: "sk-test", + }) + + expect(await Auth.get("test-provider-auth")).toEqual({ + type: "api", + key: "sk-test", + }) +}) From 3cfdb07fb85ee25b682ca485f7124b60037b6bf0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 12 Mar 2026 14:51:02 -0400 Subject: [PATCH 07/14] refactor(provider): extract ProviderAuthService Move ProviderAuth state and persistence logic into a real Effect service so the provider auth module follows the same facade-over-service migration pattern as account and auth. Keep the existing zod-facing ProviderAuth API as a thin promise bridge over the new service. --- .../opencode/src/provider/auth-service.ts | 164 ++++++++++++++++++ packages/opencode/src/provider/auth.ts | 116 ++----------- 2 files changed, 179 insertions(+), 101 deletions(-) create mode 100644 packages/opencode/src/provider/auth-service.ts diff --git a/packages/opencode/src/provider/auth-service.ts b/packages/opencode/src/provider/auth-service.ts new file mode 100644 index 00000000000..a97ce1840c6 --- /dev/null +++ b/packages/opencode/src/provider/auth-service.ts @@ -0,0 +1,164 @@ +import { Effect, Layer, ServiceMap } from "effect" +import { Instance } from "@/project/instance" +import { Plugin } from "../plugin" +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 { 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 +} + +export type Authorization = { + url: string + method: "auto" | "code" + instructions: string +} + +export const OauthMissing = NamedError.create( + "ProviderAuthOauthMissing", + z.object({ + providerID: ProviderID.zod, + }), +) + +export const OauthCodeMissing = NamedError.create( + "ProviderAuthOauthCodeMissing", + z.object({ + providerID: ProviderID.zod, + }), +) + +export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({})) + +export type ProviderAuthError = + | Auth.AuthServiceError + | InstanceType + | InstanceType + | InstanceType + +export namespace ProviderAuthService { + export interface Service { + readonly methods: () => Effect.Effect> + readonly authorize: (input: { providerID: ProviderID; method: number }) => Effect.Effect + readonly callback: (input: { + providerID: ProviderID + method: number + code?: string + }) => Effect.Effect + readonly api: (input: { providerID: ProviderID; key: string }) => Effect.Effect + } +} + +export class ProviderAuthService extends ServiceMap.Service()( + "@opencode/ProviderAuth", +) { + static readonly layer = Layer.effect( + ProviderAuthService, + Effect.gen(function* () { + const auth = yield* Auth.AuthService + + 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 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] + if (method.type !== "oauth") return + const result = yield* Effect.promise(() => method.authorize()) + yield* Effect.promise(() => + state().then((x) => { + x.pending[input.providerID] = result + }), + ) + return { + url: result.url, + method: result.method, + instructions: result.instructions, + } + }) + + const callback = Effect.fn("ProviderAuthService.callback")(function* (input: { + providerID: ProviderID + method: number + code?: string + }) { + const match = yield* Effect.promise(() => state().then((x) => x.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 (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({})) + + if ("key" in result) { + yield* auth.set(input.providerID, { + type: "api", + key: result.key, + }) + } + + if ("refresh" in result) { + yield* auth.set(input.providerID, { + type: "oauth", + access: result.access, + refresh: result.refresh, + expires: result.expires, + ...(result.accountId ? { accountId: result.accountId } : {}), + }) + } + }) + + const api = Effect.fn("ProviderAuthService.api")(function* (input: { providerID: ProviderID; key: string }) { + yield* auth.set(input.providerID, { + type: "api", + key: input.key, + }) + }) + + return ProviderAuthService.of({ + methods, + authorize, + callback, + api, + }) + }), + ) + + static readonly defaultLayer = ProviderAuthService.layer.pipe(Layer.provide(Auth.AuthService.defaultLayer)) +} diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 29e519e048f..bc53d874c4c 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -1,30 +1,17 @@ -import { Instance } from "@/project/instance" -import { runtime } from "@/effect/runtime" -import { AuthService } from "@/auth/service" -import { Plugin } from "../plugin" -import { map, filter, pipe, fromEntries, mapValues } from "remeda" +import { Effect, ManagedRuntime } from "effect" import z from "zod" + import { fn } from "@/util/fn" -import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin" -import { NamedError } from "@opencode-ai/util/error" -import { Auth } from "@/auth" +import * as S from "./auth-service" import { ProviderID } from "./schema" -export namespace ProviderAuth { - function set(key: string, info: Auth.Info) { - return runtime.runPromise(AuthService.use((service) => service.set(key, info))) - } +const rt = ManagedRuntime.make(S.ProviderAuthService.defaultLayer) - 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 } - }) +function runPromise(f: (service: S.ProviderAuthService.Service) => Effect.Effect) { + return rt.runPromise(S.ProviderAuthService.use(f)) +} +export namespace ProviderAuth { export const Method = z .object({ type: z.union([z.literal("oauth"), z.literal("api")]), @@ -36,15 +23,7 @@ export namespace ProviderAuth { export type Method = z.infer export async function methods() { - const s = await state().then((x) => x.methods) - return mapValues(s, (x) => - x.methods.map( - (y): Method => ({ - type: y.type, - label: y.label, - }), - ), - ) + return runPromise((service) => service.methods()) } export const Authorization = z @@ -63,19 +42,7 @@ export namespace ProviderAuth { providerID: ProviderID.zod, method: z.number(), }), - async (input): Promise => { - const auth = await state().then((s) => s.methods[input.providerID]) - const method = auth.methods[input.method] - if (method.type === "oauth") { - const result = await method.authorize() - await state().then((s) => (s.pending[input.providerID] = result)) - return { - url: result.url, - method: result.method, - instructions: result.instructions, - } - } - }, + async (input): Promise => runPromise((service) => service.authorize(input)), ) export const callback = fn( @@ -84,44 +51,7 @@ export namespace ProviderAuth { method: z.number(), code: z.string().optional(), }), - async (input) => { - const match = await state().then((s) => s.pending[input.providerID]) - if (!match) throw new OauthMissing({ providerID: input.providerID }) - let result - - if (match.method === "code") { - if (!input.code) throw new OauthCodeMissing({ providerID: input.providerID }) - result = await match.callback(input.code) - } - - if (match.method === "auto") { - result = await match.callback() - } - - if (result?.type === "success") { - if ("key" in result) { - await set(input.providerID, { - type: "api", - key: result.key, - }) - } - if ("refresh" in result) { - const info: Auth.Info = { - type: "oauth", - access: result.access, - refresh: result.refresh, - expires: result.expires, - } - if (result.accountId) { - info.accountId = result.accountId - } - await set(input.providerID, info) - } - return - } - - throw new OauthCallbackFailed({}) - }, + async (input) => runPromise((service) => service.callback(input)), ) export const api = fn( @@ -129,26 +59,10 @@ export namespace ProviderAuth { providerID: ProviderID.zod, key: z.string(), }), - async (input) => { - await set(input.providerID, { - type: "api", - key: input.key, - }) - }, - ) - - export const OauthMissing = NamedError.create( - "ProviderAuthOauthMissing", - z.object({ - providerID: ProviderID.zod, - }), - ) - export const OauthCodeMissing = NamedError.create( - "ProviderAuthOauthCodeMissing", - z.object({ - providerID: ProviderID.zod, - }), + async (input) => runPromise((service) => service.api(input)), ) - export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({})) + export import OauthMissing = S.OauthMissing + export import OauthCodeMissing = S.OauthCodeMissing + export import OauthCallbackFailed = S.OauthCallbackFailed } From fedaeea9da3406b243a887c68ca650e3f62bd75d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 12 Mar 2026 15:16:07 -0400 Subject: [PATCH 08/14] refactor(state): add effectful ScopedState Add a real effect-style scoped state data type built on ScopedCache and cover its caching, invalidation, concurrency, and scope-finalization semantics with focused tests. Move ProviderAuthService onto the new abstraction so the service no longer depends on Instance.state directly. --- .../opencode/src/provider/auth-service.ts | 38 ++-- packages/opencode/src/provider/auth.ts | 3 + packages/opencode/src/util/scoped-state.ts | 39 +++++ .../opencode/test/util/scoped-state.test.ts | 164 ++++++++++++++++++ 4 files changed, 225 insertions(+), 19 deletions(-) create mode 100644 packages/opencode/src/util/scoped-state.ts create mode 100644 packages/opencode/test/util/scoped-state.test.ts diff --git a/packages/opencode/src/provider/auth-service.ts b/packages/opencode/src/provider/auth-service.ts index a97ce1840c6..a49b92f52d0 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 bc53d874c4c..d513085392e 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 00000000000..b709ce64636 --- /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 00000000000..b54c4c89f92 --- /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"]) +}) From 395a9580bdcd9d18a1d94d7bceaa71ec0dc25f19 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 12 Mar 2026 16:03:32 -0400 Subject: [PATCH 09/14] 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 --- packages/opencode/src/project/instance.ts | 6 +- .../opencode/src/provider/auth-service.ts | 58 +++---- packages/opencode/src/util/instance-state.ts | 48 +++++ packages/opencode/src/util/scoped-state.ts | 39 ----- packages/opencode/test/project/state.test.ts | 87 ++++++++++ .../opencode/test/util/instance-state.test.ts | 139 +++++++++++++++ .../opencode/test/util/scoped-state.test.ts | 164 ------------------ 7 files changed, 305 insertions(+), 236 deletions(-) create mode 100644 packages/opencode/src/util/instance-state.ts delete mode 100644 packages/opencode/src/util/scoped-state.ts create mode 100644 packages/opencode/test/project/state.test.ts create mode 100644 packages/opencode/test/util/instance-state.test.ts delete mode 100644 packages/opencode/test/util/scoped-state.test.ts diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index df44a3a229c..425dc6611b1 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -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; 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) }, diff --git a/packages/opencode/src/provider/auth-service.ts b/packages/opencode/src/provider/auth-service.ts index a49b92f52d0..951a1dba483 100644 --- a/packages/opencode/src/provider/auth-service.ts +++ b/packages/opencode/src/provider/auth-service.ts @@ -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> + + /** Start an OAuth authorization flow for a provider. Returns the URL to redirect to. */ readonly authorize: (input: { providerID: ProviderID; method: number }) => Effect.Effect + + /** 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 + + /** Set an API key directly for a provider (no OAuth flow). */ readonly api: (input: { providerID: ProviderID; key: string }) => Effect.Effect } } @@ -62,8 +69,7 @@ export class ProviderAuthService extends ServiceMap.Service Instance.directory, + const state = yield* InstanceState.make({ lookup: () => Effect.promise(async () => { const methods = pipe( @@ -76,30 +82,22 @@ export class ProviderAuthService extends ServiceMap.Service - 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 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({})) diff --git a/packages/opencode/src/util/instance-state.ts b/packages/opencode/src/util/instance-state.ts new file mode 100644 index 00000000000..7d6a6d9768d --- /dev/null +++ b/packages/opencode/src/util/instance-state.ts @@ -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 + +const tasks = new Set() + +export interface InstanceState { + readonly [TypeId]: typeof TypeId + readonly cache: ScopedCache.ScopedCache +} + +export const make = (input: { + lookup: (key: string) => Effect.Effect + release?: (value: A, key: string) => Effect.Effect +}): Effect.Effect, never, R | Scope.Scope> => + Effect.gen(function* () { + const cache = yield* ScopedCache.make({ + 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 = (self: InstanceState) => ScopedCache.get(self.cache, Instance.directory) + +export const has = (self: InstanceState) => ScopedCache.has(self.cache, Instance.directory) + +export const invalidate = (self: InstanceState) => + ScopedCache.invalidate(self.cache, Instance.directory) + +export const dispose = (key: string) => + Effect.all( + [...tasks].map((task) => task(key)), + { concurrency: "unbounded" }, + ) diff --git a/packages/opencode/src/util/scoped-state.ts b/packages/opencode/src/util/scoped-state.ts deleted file mode 100644 index b709ce64636..00000000000 --- a/packages/opencode/src/util/scoped-state.ts +++ /dev/null @@ -1,39 +0,0 @@ -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/project/state.test.ts b/packages/opencode/test/project/state.test.ts new file mode 100644 index 00000000000..314343d671c --- /dev/null +++ b/packages/opencode/test/project/state.test.ts @@ -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() + 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") +}) diff --git a/packages/opencode/test/util/instance-state.test.ts b/packages/opencode/test/util/instance-state.test.ts new file mode 100644 index 00000000000..bd2df4bbf64 --- /dev/null +++ b/packages/opencode/test/util/instance-state.test.ts @@ -0,0 +1,139 @@ +import { afterEach, expect, test } from "bun:test" +import { Effect } from "effect" + +import { Instance } from "../../src/project/instance" +import * as InstanceState from "../../src/util/instance-state" +import { tmpdir } from "../fixture/fixture" + +async function access(state: InstanceState.InstanceState, dir: string) { + return Instance.provide({ + directory: dir, + fn: () => Effect.runPromise(InstanceState.get(state)), + }) +} + +afterEach(async () => { + await Instance.disposeAll() +}) + +test("InstanceState caches values for the same instance", async () => { + await using tmp = await tmpdir() + let n = 0 + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const state = yield* InstanceState.make({ + lookup: () => Effect.sync(() => ({ n: ++n })), + }) + + const a = yield* Effect.promise(() => access(state, tmp.path)) + const b = yield* Effect.promise(() => access(state, tmp.path)) + + expect(a).toBe(b) + expect(n).toBe(1) + }), + ), + ) +}) + +test("InstanceState isolates values by directory", async () => { + await using a = await tmpdir() + await using b = await tmpdir() + let n = 0 + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const state = yield* InstanceState.make({ + lookup: (dir) => Effect.sync(() => ({ dir, n: ++n })), + }) + + const x = yield* Effect.promise(() => access(state, a.path)) + const y = yield* Effect.promise(() => access(state, b.path)) + const z = yield* Effect.promise(() => access(state, a.path)) + + expect(x).toBe(z) + expect(x).not.toBe(y) + expect(n).toBe(2) + }), + ), + ) +}) + +test("InstanceState is disposed on instance reload", async () => { + await using tmp = await tmpdir() + const seen: string[] = [] + let n = 0 + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const state = yield* InstanceState.make({ + lookup: () => Effect.sync(() => ({ n: ++n })), + release: (value) => + Effect.sync(() => { + seen.push(String(value.n)) + }), + }) + + const a = yield* Effect.promise(() => access(state, tmp.path)) + yield* Effect.promise(() => Instance.reload({ directory: tmp.path })) + const b = yield* Effect.promise(() => access(state, tmp.path)) + + expect(a).not.toBe(b) + expect(seen).toEqual(["1"]) + }), + ), + ) +}) + +test("InstanceState is disposed on disposeAll", async () => { + await using a = await tmpdir() + await using b = await tmpdir() + const seen: string[] = [] + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const state = yield* InstanceState.make({ + lookup: (dir) => Effect.sync(() => ({ dir })), + release: (value) => + Effect.sync(() => { + seen.push(value.dir) + }), + }) + + yield* Effect.promise(() => access(state, a.path)) + yield* Effect.promise(() => access(state, b.path)) + yield* Effect.promise(() => Instance.disposeAll()) + + expect(seen.sort()).toEqual([a.path, b.path].sort()) + }), + ), + ) +}) + +test("InstanceState dedupes concurrent lookups for the same directory", async () => { + await using tmp = await tmpdir() + let n = 0 + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const state = yield* InstanceState.make({ + lookup: () => + Effect.promise(async () => { + n += 1 + await Bun.sleep(10) + return { n } + }), + }) + + const [a, b] = yield* Effect.promise(() => Promise.all([access(state, tmp.path), access(state, tmp.path)])) + expect(a).toBe(b) + expect(n).toBe(1) + }), + ), + ) +}) diff --git a/packages/opencode/test/util/scoped-state.test.ts b/packages/opencode/test/util/scoped-state.test.ts deleted file mode 100644 index b54c4c89f92..00000000000 --- a/packages/opencode/test/util/scoped-state.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -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"]) -}) From c45fbb3d4536d2fa9fb699c499060a32b4cbd186 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 12 Mar 2026 20:31:15 -0400 Subject: [PATCH 10/14] refactor(state): namespace InstanceState, use Map for pending - Convert InstanceState to namespace export pattern - Change pending OAuth state from Record to Map for type-safe lookups --- packages/opencode/src/project/instance.ts | 2 +- .../opencode/src/provider/auth-service.ts | 8 +-- packages/opencode/src/util/instance-state.ts | 68 ++++++++++--------- .../opencode/test/util/instance-state.test.ts | 4 +- 4 files changed, 42 insertions(+), 40 deletions(-) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 425dc6611b1..dac5e71ba13 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -6,7 +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" +import { InstanceState } from "@/util/instance-state" interface Context { directory: string diff --git a/packages/opencode/src/provider/auth-service.ts b/packages/opencode/src/provider/auth-service.ts index 951a1dba483..f6af2d0b7e1 100644 --- a/packages/opencode/src/provider/auth-service.ts +++ b/packages/opencode/src/provider/auth-service.ts @@ -5,7 +5,7 @@ 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 { InstanceState } from "@/util/instance-state" import { ProviderID } from "./schema" import z from "zod" @@ -78,7 +78,7 @@ export class ProviderAuthService extends ServiceMap.Service [x.auth!.provider, x.auth!] as const), fromEntries(), ) - return { methods, pending: {} as Record } + return { methods, pending: new Map() } }), }) @@ -97,7 +97,7 @@ export class ProviderAuthService extends ServiceMap.Service method.authorize()) const s = yield* InstanceState.get(state) - s.pending[input.providerID] = result + s.pending.set(input.providerID, result) return { url: result.url, method: result.method, @@ -110,7 +110,7 @@ export class ProviderAuthService extends ServiceMap.Service Effect.Effect const tasks = new Set() -export interface InstanceState { - readonly [TypeId]: typeof TypeId - readonly cache: ScopedCache.ScopedCache -} - -export const make = (input: { - lookup: (key: string) => Effect.Effect - release?: (value: A, key: string) => Effect.Effect -}): Effect.Effect, never, R | Scope.Scope> => - Effect.gen(function* () { - const cache = yield* ScopedCache.make({ - capacity: Number.POSITIVE_INFINITY, - lookup: (key) => - Effect.acquireRelease(input.lookup(key), (value) => (input.release ? input.release(value, key) : Effect.void)), +export namespace InstanceState { + export interface State { + readonly [TypeId]: typeof TypeId + readonly cache: ScopedCache.ScopedCache + } + + export const make = (input: { + lookup: (key: string) => Effect.Effect + release?: (value: A, key: string) => Effect.Effect + }): Effect.Effect, never, R | Scope.Scope> => + Effect.gen(function* () { + const cache = yield* ScopedCache.make({ + 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, + } }) - 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 = (self: State) => ScopedCache.get(self.cache, Instance.directory) -export const get = (self: InstanceState) => ScopedCache.get(self.cache, Instance.directory) + export const has = (self: State) => ScopedCache.has(self.cache, Instance.directory) -export const has = (self: InstanceState) => ScopedCache.has(self.cache, Instance.directory) + export const invalidate = (self: State) => + ScopedCache.invalidate(self.cache, Instance.directory) -export const invalidate = (self: InstanceState) => - ScopedCache.invalidate(self.cache, Instance.directory) - -export const dispose = (key: string) => - Effect.all( - [...tasks].map((task) => task(key)), - { concurrency: "unbounded" }, - ) + export const dispose = (key: string) => + Effect.all( + [...tasks].map((task) => task(key)), + { concurrency: "unbounded" }, + ) +} diff --git a/packages/opencode/test/util/instance-state.test.ts b/packages/opencode/test/util/instance-state.test.ts index bd2df4bbf64..e5d2129fb07 100644 --- a/packages/opencode/test/util/instance-state.test.ts +++ b/packages/opencode/test/util/instance-state.test.ts @@ -2,10 +2,10 @@ import { afterEach, expect, test } from "bun:test" import { Effect } from "effect" import { Instance } from "../../src/project/instance" -import * as InstanceState from "../../src/util/instance-state" +import { InstanceState } from "../../src/util/instance-state" import { tmpdir } from "../fixture/fixture" -async function access(state: InstanceState.InstanceState, dir: string) { +async function access(state: InstanceState.State, dir: string) { return Instance.provide({ directory: dir, fn: () => Effect.runPromise(InstanceState.get(state)), From 106b7a9d1596088d4de41d9374aa0073335eb04d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 12 Mar 2026 20:57:19 -0400 Subject: [PATCH 11/14] fix(provider): use ProviderID type for pending OAuth map key --- packages/opencode/src/provider/auth-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/auth-service.ts b/packages/opencode/src/provider/auth-service.ts index f6af2d0b7e1..53d219721fe 100644 --- a/packages/opencode/src/provider/auth-service.ts +++ b/packages/opencode/src/provider/auth-service.ts @@ -78,7 +78,7 @@ export class ProviderAuthService extends ServiceMap.Service [x.auth!.provider, x.auth!] as const), fromEntries(), ) - return { methods, pending: new Map() } + return { methods, pending: new Map() } }), }) From 2c1984e222b707aa581ca41bdd3c631b86d5f546 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 12 Mar 2026 21:10:30 -0400 Subject: [PATCH 12/14] test(state): rewrite State tests to go through Instance.state --- packages/opencode/test/project/state.test.ts | 146 +++++++++++-------- 1 file changed, 87 insertions(+), 59 deletions(-) diff --git a/packages/opencode/test/project/state.test.ts b/packages/opencode/test/project/state.test.ts index 314343d671c..c1a6dab3154 100644 --- a/packages/opencode/test/project/state.test.ts +++ b/packages/opencode/test/project/state.test.ts @@ -1,87 +1,115 @@ -import { expect, test } from "bun:test" +import { afterEach, expect, test } from "bun:test" -import { State } from "../../src/project/state" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" -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") +afterEach(async () => { + await Instance.disposeAll() }) -test("State.create isolates values by key", async () => { - let key = "a" +test("Instance.state caches values for the same instance", async () => { + await using tmp = await tmpdir() let n = 0 - const state = State.create( - () => key, - () => ({ n: ++n }), - ) - - const a = state() - key = "b" - const b = state() - key = "a" - const c = state() + const state = Instance.state(() => ({ n: ++n })) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const a = state() + const b = state() + expect(a).toBe(b) + expect(n).toBe(1) + }, + }) +}) - expect(a).toBe(c) - expect(a).not.toBe(b) +test("Instance.state isolates values by directory", async () => { + await using a = await tmpdir() + await using b = await tmpdir() + let n = 0 + const state = Instance.state(() => ({ n: ++n })) + + const x = await Instance.provide({ + directory: a.path, + fn: async () => state(), + }) + const y = await Instance.provide({ + directory: b.path, + fn: async () => state(), + }) + const z = await Instance.provide({ + directory: a.path, + fn: async () => state(), + }) + + expect(x).toBe(z) + expect(x).not.toBe(y) expect(n).toBe(2) - - await State.dispose("a") - await State.dispose("b") }) -test("State.dispose clears a key and runs cleanup", async () => { +test("Instance.state is disposed on instance reload", async () => { + await using tmp = await tmpdir() const seen: string[] = [] - let key = "a" let n = 0 - const state = State.create( - () => key, + const state = Instance.state( () => ({ n: ++n }), async (value) => { seen.push(String(value.n)) }, ) - const a = state() - await State.dispose("a") - const b = state() + const a = await Instance.provide({ + directory: tmp.path, + fn: async () => state(), + }) + await Instance.reload({ directory: tmp.path }) + const b = await Instance.provide({ + directory: tmp.path, + fn: async () => 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() - let n = 0 - const state = State.create( - () => "a", - async () => { - n += 1 - await gate.promise - return { n } +test("Instance.state is disposed on disposeAll", async () => { + await using a = await tmpdir() + await using b = await tmpdir() + const seen: string[] = [] + const state = Instance.state( + () => ({ dir: Instance.directory }), + async (value) => { + seen.push(value.dir) }, ) - const task = Promise.all([state(), state()]) - await Promise.resolve() - expect(n).toBe(1) + await Instance.provide({ + directory: a.path, + fn: async () => state(), + }) + await Instance.provide({ + directory: b.path, + fn: async () => state(), + }) + await Instance.disposeAll() + + expect(seen.sort()).toEqual([a.path, b.path].sort()) +}) - gate.resolve() - const [a, b] = await task - expect(a).toBe(b) +test("Instance.state dedupes concurrent promise initialization", async () => { + await using tmp = await tmpdir() + let n = 0 + const state = Instance.state(async () => { + n += 1 + await Bun.sleep(10) + return { n } + }) - await State.dispose("a") + const [a, b] = await Instance.provide({ + directory: tmp.path, + fn: async () => Promise.all([state(), state()]), + }) + + expect(a).toBe(b) + expect(n).toBe(1) }) From eb3ab56bdd75ad7d5fbb503cb00ddb682d8c475b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 12 Mar 2026 21:38:18 -0400 Subject: [PATCH 13/14] refactor(provider): single InstanceState.get per method --- packages/opencode/src/provider/auth-service.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/provider/auth-service.ts b/packages/opencode/src/provider/auth-service.ts index 53d219721fe..e7d340a3a2d 100644 --- a/packages/opencode/src/provider/auth-service.ts +++ b/packages/opencode/src/provider/auth-service.ts @@ -91,12 +91,10 @@ export class ProviderAuthService extends ServiceMap.Service method.authorize()) - - const s = yield* InstanceState.get(state) s.pending.set(input.providerID, result) return { url: result.url, @@ -110,7 +108,8 @@ export class ProviderAuthService extends ServiceMap.Service Date: Thu, 12 Mar 2026 21:54:26 -0400 Subject: [PATCH 14/14] refactor(provider): co-locate auth schemas with service types --- .../opencode/src/provider/auth-service.ts | 30 ++++++++++++------- packages/opencode/src/provider/auth.ts | 23 +++----------- 2 files changed, 24 insertions(+), 29 deletions(-) diff --git a/packages/opencode/src/provider/auth-service.ts b/packages/opencode/src/provider/auth-service.ts index e7d340a3a2d..4b5ac1777a8 100644 --- a/packages/opencode/src/provider/auth-service.ts +++ b/packages/opencode/src/provider/auth-service.ts @@ -9,16 +9,26 @@ import { InstanceState } from "@/util/instance-state" import { ProviderID } from "./schema" import z from "zod" -export type Method = { - type: "oauth" | "api" - label: string -} - -export type Authorization = { - url: string - method: "auto" | "code" - instructions: string -} +export const Method = z + .object({ + type: z.union([z.literal("oauth"), z.literal("api")]), + label: z.string(), + }) + .meta({ + ref: "ProviderAuthMethod", + }) +export type Method = z.infer + +export const Authorization = z + .object({ + url: z.string(), + method: z.union([z.literal("auto"), z.literal("code")]), + instructions: z.string(), + }) + .meta({ + ref: "ProviderAuthAuthorization", + }) +export type Authorization = z.infer export const OauthMissing = NamedError.create( "ProviderAuthOauthMissing", diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index d513085392e..095c6e57ed7 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -15,30 +15,15 @@ function runPromise(f: (service: S.ProviderAuthService.Service) => Effect.Eff } export namespace ProviderAuth { - export const Method = z - .object({ - type: z.union([z.literal("oauth"), z.literal("api")]), - label: z.string(), - }) - .meta({ - ref: "ProviderAuthMethod", - }) - export type Method = z.infer + export const Method = S.Method + export type Method = S.Method export async function methods() { return runPromise((service) => service.methods()) } - export const Authorization = z - .object({ - url: z.string(), - method: z.union([z.literal("auto"), z.literal("code")]), - instructions: z.string(), - }) - .meta({ - ref: "ProviderAuthAuthorization", - }) - export type Authorization = z.infer + export const Authorization = S.Authorization + export type Authorization = S.Authorization export const authorize = fn( z.object({