From c687262c5948ee68508c90e0b3d1502ca823a524 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 17 Mar 2026 21:07:40 -0400 Subject: [PATCH] refactor(effect): move account auth question modules --- packages/opencode/src/account/effect.ts | 355 +++++++++++++++++ packages/opencode/src/account/index.ts | 28 +- packages/opencode/src/account/service.ts | 359 ------------------ packages/opencode/src/auth/effect.ts | 98 +++++ packages/opencode/src/auth/index.ts | 8 +- packages/opencode/src/auth/service.ts | 101 ----- packages/opencode/src/cli/cmd/account.ts | 12 +- packages/opencode/src/effect/instances.ts | 8 +- packages/opencode/src/effect/runtime.ts | 8 +- .../src/permission/{service.ts => effect.ts} | 10 +- packages/opencode/src/permission/index.ts | 2 +- .../opencode/src/provider/auth-service.ts | 8 +- packages/opencode/src/question/effect.ts | 168 ++++++++ packages/opencode/src/question/index.ts | 34 +- packages/opencode/src/question/service.ts | 172 --------- packages/opencode/src/tool/truncate-effect.ts | 2 +- .../opencode/test/account/service.test.ts | 12 +- .../opencode/test/permission/next.test.ts | 2 +- 18 files changed, 691 insertions(+), 696 deletions(-) create mode 100644 packages/opencode/src/account/effect.ts delete mode 100644 packages/opencode/src/account/service.ts create mode 100644 packages/opencode/src/auth/effect.ts delete mode 100644 packages/opencode/src/auth/service.ts rename packages/opencode/src/permission/{service.ts => effect.ts} (95%) create mode 100644 packages/opencode/src/question/effect.ts delete mode 100644 packages/opencode/src/question/service.ts diff --git a/packages/opencode/src/account/effect.ts b/packages/opencode/src/account/effect.ts new file mode 100644 index 0000000000..845a08bfb2 --- /dev/null +++ b/packages/opencode/src/account/effect.ts @@ -0,0 +1,355 @@ +import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" + +import { withTransientReadRetry } from "@/util/effect-http-client" +import { AccountRepo, type AccountRow } from "./repo" +import { + type AccountError as SchemaError, + AccessToken as SchemaAccessToken, + Account as SchemaAccount, + AccountID as SchemaAccountID, + DeviceCode as SchemaDeviceCode, + RefreshToken as SchemaRefreshToken, + AccountServiceError as SchemaServiceError, + Login as SchemaLogin, + Org as SchemaOrg, + OrgID as SchemaOrgID, + PollDenied as SchemaPollDenied, + PollError as SchemaPollError, + PollExpired as SchemaPollExpired, + PollPending as SchemaPollPending, + type PollResult as SchemaPollResult, + PollSlow as SchemaPollSlow, + PollSuccess as SchemaPollSuccess, + UserCode as SchemaUserCode, +} from "./schema" + +export namespace AccountEffect { + export type Error = SchemaError + + const AccessToken = SchemaAccessToken + type AccessToken = SchemaAccessToken + const Account = SchemaAccount + type Account = SchemaAccount + const AccountID = SchemaAccountID + type AccountID = SchemaAccountID + const DeviceCode = SchemaDeviceCode + type DeviceCode = SchemaDeviceCode + const RefreshToken = SchemaRefreshToken + type RefreshToken = SchemaRefreshToken + const Login = SchemaLogin + type Login = SchemaLogin + const Org = SchemaOrg + type Org = SchemaOrg + const OrgID = SchemaOrgID + type OrgID = SchemaOrgID + const PollDenied = SchemaPollDenied + const PollError = SchemaPollError + const PollExpired = SchemaPollExpired + const PollPending = SchemaPollPending + const PollSlow = SchemaPollSlow + const PollSuccess = SchemaPollSuccess + const UserCode = SchemaUserCode + type PollResult = SchemaPollResult + + export type AccountOrgs = { + account: Account + orgs: readonly Org[] + } + + class RemoteConfig extends Schema.Class("RemoteConfig")({ + config: Schema.Record(Schema.String, Schema.Json), + }) {} + + const DurationFromSeconds = Schema.Number.pipe( + Schema.decodeTo(Schema.Duration, { + decode: SchemaGetter.transform((n) => Duration.seconds(n)), + encode: SchemaGetter.transform((d) => Duration.toSeconds(d)), + }), + ) + + class TokenRefresh extends Schema.Class("TokenRefresh")({ + access_token: AccessToken, + refresh_token: RefreshToken, + expires_in: DurationFromSeconds, + }) {} + + class DeviceAuth extends Schema.Class("DeviceAuth")({ + device_code: DeviceCode, + user_code: UserCode, + verification_uri_complete: Schema.String, + expires_in: DurationFromSeconds, + interval: DurationFromSeconds, + }) {} + + class DeviceTokenSuccess extends Schema.Class("DeviceTokenSuccess")({ + access_token: AccessToken, + refresh_token: RefreshToken, + token_type: Schema.Literal("Bearer"), + expires_in: DurationFromSeconds, + }) {} + + class DeviceTokenError extends Schema.Class("DeviceTokenError")({ + error: Schema.String, + error_description: Schema.String, + }) { + toPollResult(): PollResult { + if (this.error === "authorization_pending") return new PollPending() + if (this.error === "slow_down") return new PollSlow() + if (this.error === "expired_token") return new PollExpired() + if (this.error === "access_denied") return new PollDenied() + return new PollError({ cause: this.error }) + } + } + + const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError]) + + class User extends Schema.Class("User")({ + id: AccountID, + email: Schema.String, + }) {} + + class ClientId extends Schema.Class("ClientId")({ client_id: Schema.String }) {} + + class DeviceTokenRequest extends Schema.Class("DeviceTokenRequest")({ + grant_type: Schema.String, + device_code: DeviceCode, + client_id: Schema.String, + }) {} + + class TokenRefreshRequest extends Schema.Class("TokenRefreshRequest")({ + grant_type: Schema.String, + refresh_token: RefreshToken, + client_id: Schema.String, + }) {} + + const client_id = "opencode-cli" + + const map = + (message = "Account service operation failed") => + (effect: Effect.Effect): Effect.Effect => + effect.pipe( + Effect.mapError((cause) => + cause instanceof SchemaServiceError ? cause : new SchemaServiceError({ message, cause }), + ), + ) + + export interface Interface { + readonly active: () => Effect.Effect, Error> + readonly list: () => Effect.Effect + readonly orgsByAccount: () => Effect.Effect + readonly remove: (accountID: AccountID) => Effect.Effect + readonly use: (accountID: AccountID, orgID: Option.Option) => Effect.Effect + readonly orgs: (accountID: AccountID) => Effect.Effect + readonly config: (accountID: AccountID, orgID: OrgID) => Effect.Effect>, Error> + readonly token: (accountID: AccountID) => Effect.Effect, Error> + readonly login: (url: string) => Effect.Effect + readonly poll: (input: Login) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/Account") {} + + export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const repo = yield* AccountRepo + const http = yield* HttpClient.HttpClient + const httpRead = withTransientReadRetry(http) + const httpOk = HttpClient.filterStatusOk(http) + const httpReadOk = HttpClient.filterStatusOk(httpRead) + + const executeRead = (request: HttpClientRequest.HttpClientRequest) => + httpRead.execute(request).pipe(map("HTTP request failed")) + + const executeReadOk = (request: HttpClientRequest.HttpClientRequest) => + httpReadOk.execute(request).pipe(map("HTTP request failed")) + + const executeEffectOk = (request: Effect.Effect) => + request.pipe( + Effect.flatMap((req) => httpOk.execute(req)), + map("HTTP request failed"), + ) + + const resolveToken = Effect.fnUntraced(function* (row: AccountRow) { + const now = yield* Clock.currentTimeMillis + if (row.token_expiry && row.token_expiry > now) return row.access_token + + const response = yield* executeEffectOk( + HttpClientRequest.post(`${row.url}/auth/device/token`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.schemaBodyJson(TokenRefreshRequest)( + new TokenRefreshRequest({ + grant_type: "refresh_token", + refresh_token: row.refresh_token, + client_id, + }), + ), + ), + ) + + const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe( + map("Failed to decode response"), + ) + + const expiry = Option.some(now + Duration.toMillis(parsed.expires_in)) + + yield* repo.persistToken({ + accountID: row.id, + accessToken: parsed.access_token, + refreshToken: parsed.refresh_token, + expiry, + }) + + return parsed.access_token + }) + + const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) { + const maybe = yield* repo.getRow(accountID) + if (Option.isNone(maybe)) return Option.none() + + const account = maybe.value + const accessToken = yield* resolveToken(account) + return Option.some({ account, accessToken }) + }) + + const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) { + const response = yield* executeReadOk( + HttpClientRequest.get(`${url}/api/orgs`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(accessToken), + ), + ) + + return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe( + map("Failed to decode response"), + ) + }) + + const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) { + const response = yield* executeReadOk( + HttpClientRequest.get(`${url}/api/user`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(accessToken), + ), + ) + + return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(map("Failed to decode response")) + }) + + const token = Effect.fn("Account.token")((accountID: AccountID) => + resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))), + ) + + const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) { + const resolved = yield* resolveAccess(accountID) + if (Option.isNone(resolved)) return [] + + return yield* fetchOrgs(resolved.value.account.url, resolved.value.accessToken) + }) + + const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () { + const accounts = yield* repo.list() + const [errors, results] = yield* Effect.partition( + accounts, + (account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))), + { concurrency: 3 }, + ) + for (const err of errors) { + yield* Effect.logWarning("failed to fetch orgs for account").pipe(Effect.annotateLogs({ error: String(err) })) + } + return results + }) + + const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) { + const resolved = yield* resolveAccess(accountID) + if (Option.isNone(resolved)) return Option.none() + + const response = yield* executeRead( + HttpClientRequest.get(`${resolved.value.account.url}/api/config`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(resolved.value.accessToken), + HttpClientRequest.setHeaders({ "x-org-id": orgID }), + ), + ) + + if (response.status === 404) return Option.none() + + const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(map()) + const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(map("Failed to decode response")) + return Option.some(parsed.config) + }) + + const login = Effect.fn("Account.login")(function* (server: string) { + const response = yield* executeEffectOk( + HttpClientRequest.post(`${server}/auth/device/code`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id })), + ), + ) + + const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(map("Failed to decode response")) + return new Login({ + code: parsed.device_code, + user: parsed.user_code, + url: `${server}${parsed.verification_uri_complete}`, + server, + expiry: parsed.expires_in, + interval: parsed.interval, + }) + }) + + const poll = Effect.fn("Account.poll")(function* (input: Login) { + const response = yield* executeEffectOk( + HttpClientRequest.post(`${input.server}/auth/device/token`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.schemaBodyJson(DeviceTokenRequest)( + new DeviceTokenRequest({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: input.code, + client_id, + }), + ), + ), + ) + + const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(map("Failed to decode response")) + if (parsed instanceof DeviceTokenError) return parsed.toPollResult() + + const [account, remoteOrgs] = yield* Effect.all( + [fetchUser(input.server, parsed.access_token), fetchOrgs(input.server, parsed.access_token)], + { concurrency: 2 }, + ) + + const first = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none() + const expiry = (yield* Clock.currentTimeMillis) + Duration.toMillis(parsed.expires_in) + + yield* repo.persistAccount({ + id: account.id, + email: account.email, + url: input.server, + accessToken: parsed.access_token, + refreshToken: parsed.refresh_token, + expiry, + orgID: first, + }) + + return new PollSuccess({ email: account.email }) + }) + + return Service.of({ + active: repo.active, + list: repo.list, + orgsByAccount, + remove: repo.remove, + use: repo.use, + orgs, + config, + token, + login, + poll, + }) + }), + ) + + export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer)) +} diff --git a/packages/opencode/src/account/index.ts b/packages/opencode/src/account/index.ts index ed4c3d8798..e59b21266d 100644 --- a/packages/opencode/src/account/index.ts +++ b/packages/opencode/src/account/index.ts @@ -1,27 +1,33 @@ import { Effect, Option } from "effect" +import { AccountEffect } from "./effect" import { + AccessToken as Token, Account as AccountSchema, type AccountError, - type AccessToken, - AccountID, - AccountService, - OrgID, -} from "./service" - -export { AccessToken, AccountID, OrgID } from "./service" + AccountID as ID, + OrgID as Org, +} from "./schema" import { runtime } from "@/effect/runtime" -function runSync(f: (service: AccountService.Service) => Effect.Effect) { - return runtime.runSync(AccountService.use(f)) +export { AccessToken, AccountID, OrgID } from "./schema" + +function runSync(f: (service: AccountEffect.Interface) => Effect.Effect) { + return runtime.runSync(AccountEffect.Service.use(f)) } -function runPromise(f: (service: AccountService.Service) => Effect.Effect) { - return runtime.runPromise(AccountService.use(f)) +function runPromise(f: (service: AccountEffect.Interface) => Effect.Effect) { + return runtime.runPromise(AccountEffect.Service.use(f)) } export namespace Account { + export const AccessToken = Token + export type AccessToken = Token + export const AccountID = ID + export type AccountID = ID + export const OrgID = Org + export type OrgID = Org export const Account = AccountSchema export type Account = AccountSchema diff --git a/packages/opencode/src/account/service.ts b/packages/opencode/src/account/service.ts deleted file mode 100644 index 87e95c8f44..0000000000 --- a/packages/opencode/src/account/service.ts +++ /dev/null @@ -1,359 +0,0 @@ -import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect" -import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" - -import { withTransientReadRetry } from "@/util/effect-http-client" -import { AccountRepo, type AccountRow } from "./repo" -import { - type AccountError, - AccessToken, - Account, - AccountID, - DeviceCode, - RefreshToken, - AccountServiceError, - Login, - Org, - OrgID, - PollDenied, - PollError, - PollExpired, - PollPending, - type PollResult, - PollSlow, - PollSuccess, - UserCode, -} from "./schema" - -export * from "./schema" - -export type AccountOrgs = { - account: Account - orgs: readonly Org[] -} - -class RemoteConfig extends Schema.Class("RemoteConfig")({ - config: Schema.Record(Schema.String, Schema.Json), -}) {} - -const DurationFromSeconds = Schema.Number.pipe( - Schema.decodeTo(Schema.Duration, { - decode: SchemaGetter.transform((n) => Duration.seconds(n)), - encode: SchemaGetter.transform((d) => Duration.toSeconds(d)), - }), -) - -class TokenRefresh extends Schema.Class("TokenRefresh")({ - access_token: AccessToken, - refresh_token: RefreshToken, - expires_in: DurationFromSeconds, -}) {} - -class DeviceAuth extends Schema.Class("DeviceAuth")({ - device_code: DeviceCode, - user_code: UserCode, - verification_uri_complete: Schema.String, - expires_in: DurationFromSeconds, - interval: DurationFromSeconds, -}) {} - -class DeviceTokenSuccess extends Schema.Class("DeviceTokenSuccess")({ - access_token: AccessToken, - refresh_token: RefreshToken, - token_type: Schema.Literal("Bearer"), - expires_in: DurationFromSeconds, -}) {} - -class DeviceTokenError extends Schema.Class("DeviceTokenError")({ - error: Schema.String, - error_description: Schema.String, -}) { - toPollResult(): PollResult { - if (this.error === "authorization_pending") return new PollPending() - if (this.error === "slow_down") return new PollSlow() - if (this.error === "expired_token") return new PollExpired() - if (this.error === "access_denied") return new PollDenied() - return new PollError({ cause: this.error }) - } -} - -const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError]) - -class User extends Schema.Class("User")({ - id: AccountID, - email: Schema.String, -}) {} - -class ClientId extends Schema.Class("ClientId")({ client_id: Schema.String }) {} - -class DeviceTokenRequest extends Schema.Class("DeviceTokenRequest")({ - grant_type: Schema.String, - device_code: DeviceCode, - client_id: Schema.String, -}) {} - -class TokenRefreshRequest extends Schema.Class("TokenRefreshRequest")({ - grant_type: Schema.String, - refresh_token: RefreshToken, - client_id: Schema.String, -}) {} - -const clientId = "opencode-cli" - -const mapAccountServiceError = - (message = "Account service operation failed") => - (effect: Effect.Effect): Effect.Effect => - effect.pipe( - Effect.mapError((cause) => - cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }), - ), - ) - -export namespace AccountService { - export interface Service { - readonly active: () => Effect.Effect, AccountError> - readonly list: () => Effect.Effect - readonly orgsByAccount: () => Effect.Effect - readonly remove: (accountID: AccountID) => Effect.Effect - readonly use: (accountID: AccountID, orgID: Option.Option) => Effect.Effect - readonly orgs: (accountID: AccountID) => Effect.Effect - readonly config: ( - accountID: AccountID, - orgID: OrgID, - ) => Effect.Effect>, AccountError> - readonly token: (accountID: AccountID) => Effect.Effect, AccountError> - readonly login: (url: string) => Effect.Effect - readonly poll: (input: Login) => Effect.Effect - } -} - -export class AccountService extends ServiceMap.Service()("@opencode/Account") { - static readonly layer: Layer.Layer = Layer.effect( - AccountService, - Effect.gen(function* () { - const repo = yield* AccountRepo - const http = yield* HttpClient.HttpClient - const httpRead = withTransientReadRetry(http) - const httpOk = HttpClient.filterStatusOk(http) - const httpReadOk = HttpClient.filterStatusOk(httpRead) - - const executeRead = (request: HttpClientRequest.HttpClientRequest) => - httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed")) - - const executeReadOk = (request: HttpClientRequest.HttpClientRequest) => - httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed")) - - const executeEffectOk = (request: Effect.Effect) => - request.pipe( - Effect.flatMap((req) => httpOk.execute(req)), - mapAccountServiceError("HTTP request failed"), - ) - - // Returns a usable access token for a stored account row, refreshing and - // persisting it when the cached token has expired. - const resolveToken = Effect.fnUntraced(function* (row: AccountRow) { - const now = yield* Clock.currentTimeMillis - if (row.token_expiry && row.token_expiry > now) return row.access_token - - const response = yield* executeEffectOk( - HttpClientRequest.post(`${row.url}/auth/device/token`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.schemaBodyJson(TokenRefreshRequest)( - new TokenRefreshRequest({ - grant_type: "refresh_token", - refresh_token: row.refresh_token, - client_id: clientId, - }), - ), - ), - ) - - const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe( - mapAccountServiceError("Failed to decode response"), - ) - - const expiry = Option.some(now + Duration.toMillis(parsed.expires_in)) - - yield* repo.persistToken({ - accountID: row.id, - accessToken: parsed.access_token, - refreshToken: parsed.refresh_token, - expiry, - }) - - return parsed.access_token - }) - - const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) { - const maybeAccount = yield* repo.getRow(accountID) - if (Option.isNone(maybeAccount)) return Option.none() - - const account = maybeAccount.value - const accessToken = yield* resolveToken(account) - return Option.some({ account, accessToken }) - }) - - const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) { - const response = yield* executeReadOk( - HttpClientRequest.get(`${url}/api/orgs`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.bearerToken(accessToken), - ), - ) - - return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe( - mapAccountServiceError("Failed to decode response"), - ) - }) - - const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) { - const response = yield* executeReadOk( - HttpClientRequest.get(`${url}/api/user`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.bearerToken(accessToken), - ), - ) - - return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe( - mapAccountServiceError("Failed to decode response"), - ) - }) - - const token = Effect.fn("AccountService.token")((accountID: AccountID) => - resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))), - ) - - const orgsByAccount = Effect.fn("AccountService.orgsByAccount")(function* () { - const accounts = yield* repo.list() - const [errors, results] = yield* Effect.partition( - accounts, - (account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))), - { concurrency: 3 }, - ) - for (const error of errors) { - yield* Effect.logWarning("failed to fetch orgs for account").pipe( - Effect.annotateLogs({ error: String(error) }), - ) - } - return results - }) - - const orgs = Effect.fn("AccountService.orgs")(function* (accountID: AccountID) { - const resolved = yield* resolveAccess(accountID) - if (Option.isNone(resolved)) return [] - - const { account, accessToken } = resolved.value - - return yield* fetchOrgs(account.url, accessToken) - }) - - const config = Effect.fn("AccountService.config")(function* (accountID: AccountID, orgID: OrgID) { - const resolved = yield* resolveAccess(accountID) - if (Option.isNone(resolved)) return Option.none() - - const { account, accessToken } = resolved.value - - const response = yield* executeRead( - HttpClientRequest.get(`${account.url}/api/config`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.bearerToken(accessToken), - HttpClientRequest.setHeaders({ "x-org-id": orgID }), - ), - ) - - if (response.status === 404) return Option.none() - - const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError()) - - const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe( - mapAccountServiceError("Failed to decode response"), - ) - return Option.some(parsed.config) - }) - - const login = Effect.fn("AccountService.login")(function* (server: string) { - const response = yield* executeEffectOk( - HttpClientRequest.post(`${server}/auth/device/code`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })), - ), - ) - - const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe( - mapAccountServiceError("Failed to decode response"), - ) - return new Login({ - code: parsed.device_code, - user: parsed.user_code, - url: `${server}${parsed.verification_uri_complete}`, - server, - expiry: parsed.expires_in, - interval: parsed.interval, - }) - }) - - const poll = Effect.fn("AccountService.poll")(function* (input: Login) { - const response = yield* executeEffectOk( - HttpClientRequest.post(`${input.server}/auth/device/token`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.schemaBodyJson(DeviceTokenRequest)( - new DeviceTokenRequest({ - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - device_code: input.code, - client_id: clientId, - }), - ), - ), - ) - - const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe( - mapAccountServiceError("Failed to decode response"), - ) - - if (parsed instanceof DeviceTokenError) return parsed.toPollResult() - const accessToken = parsed.access_token - - const user = fetchUser(input.server, accessToken) - const orgs = fetchOrgs(input.server, accessToken) - - const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 }) - - // TODO: When there are multiple orgs, let the user choose - const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none() - - const now = yield* Clock.currentTimeMillis - const expiry = now + Duration.toMillis(parsed.expires_in) - const refreshToken = parsed.refresh_token - - yield* repo.persistAccount({ - id: account.id, - email: account.email, - url: input.server, - accessToken, - refreshToken, - expiry, - orgID: firstOrgID, - }) - - return new PollSuccess({ email: account.email }) - }) - - return AccountService.of({ - active: repo.active, - list: repo.list, - orgsByAccount, - remove: repo.remove, - use: repo.use, - orgs, - config, - token, - login, - poll, - }) - }), - ) - - static readonly defaultLayer = AccountService.layer.pipe( - Layer.provide(AccountRepo.layer), - Layer.provide(FetchHttpClient.layer), - ) -} diff --git a/packages/opencode/src/auth/effect.ts b/packages/opencode/src/auth/effect.ts new file mode 100644 index 0000000000..2c41734fcc --- /dev/null +++ b/packages/opencode/src/auth/effect.ts @@ -0,0 +1,98 @@ +import path from "path" +import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect" +import { Global } from "../global" +import { Filesystem } from "../util/filesystem" + +export namespace AuthEffect { + export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" + + 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 ApiAuth 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, ApiAuth, WellKnown]) + export type Info = Schema.Schema.Type + + export class AuthServiceError extends Schema.TaggedErrorClass()("AuthServiceError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), + }) {} + + export type Error = AuthServiceError + + const file = path.join(Global.Path.data, "auth.json") + + const fail = (message: string) => (cause: unknown) => new AuthServiceError({ message, cause }) + + export interface Interface { + 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 Service extends ServiceMap.Service()("@opencode/Auth") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const decode = Schema.decodeUnknownOption(Info) + + const all = Effect.fn("Auth.all")(() => + Effect.tryPromise({ + try: async () => { + const data = await Filesystem.readJson>(file).catch(() => ({})) + return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined)) + }, + catch: fail("Failed to read auth data"), + }), + ) + + const get = Effect.fn("Auth.get")(function* (providerID: string) { + return (yield* all())[providerID] + }) + + const set = Effect.fn("Auth.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("Auth.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 Service.of({ get, all, set, remove }) + }), + ) + + export const defaultLayer = layer +} diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 79e9e615d2..fd38dd8729 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,12 +1,12 @@ import { Effect } from "effect" import z from "zod" import { runtime } from "@/effect/runtime" -import * as S from "./service" +import * as S from "./effect" -export { OAUTH_DUMMY_KEY } from "./service" +export const OAUTH_DUMMY_KEY = S.AuthEffect.OAUTH_DUMMY_KEY -function runPromise(f: (service: S.AuthService.Service) => Effect.Effect) { - return runtime.runPromise(S.AuthService.use(f)) +function runPromise(f: (service: S.AuthEffect.Interface) => Effect.Effect) { + return runtime.runPromise(S.AuthEffect.Service.use(f)) } export namespace Auth { diff --git a/packages/opencode/src/auth/service.ts b/packages/opencode/src/auth/service.ts deleted file mode 100644 index 100a132b87..0000000000 --- a/packages/opencode/src/auth/service.ts +++ /dev/null @@ -1,101 +0,0 @@ -import path from "path" -import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect" -import { Global } from "../global" -import { Filesystem } from "../util/filesystem" - -export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" - -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, - 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 decode = Schema.decodeUnknownOption(Info) - - const all = Effect.fn("AuthService.all")(() => - Effect.tryPromise({ - try: async () => { - const data = await Filesystem.readJson>(file).catch(() => ({})) - return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined)) - }, - 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/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts index b2256837dc..f0dfe9edfe 100644 --- a/packages/opencode/src/cli/cmd/account.ts +++ b/packages/opencode/src/cli/cmd/account.ts @@ -2,8 +2,8 @@ import { cmd } from "./cmd" import { Duration, Effect, Match, Option } from "effect" import { UI } from "../ui" import { runtime } from "@/effect/runtime" -import { AccountID, AccountService, OrgID, PollExpired, type PollResult } from "@/account/service" -import { type AccountError } from "@/account/schema" +import { AccountEffect } from "@/account/effect" +import { type AccountError, AccountID, OrgID, PollExpired, type PollResult } from "@/account/schema" import * as Prompt from "../effect/prompt" import open from "open" @@ -17,7 +17,7 @@ const isActiveOrgChoice = ( ) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID const loginEffect = Effect.fn("login")(function* (url: string) { - const service = yield* AccountService + const service = yield* AccountEffect.Service yield* Prompt.intro("Log in") const login = yield* service.login(url) @@ -58,7 +58,7 @@ const loginEffect = Effect.fn("login")(function* (url: string) { }) const logoutEffect = Effect.fn("logout")(function* (email?: string) { - const service = yield* AccountService + const service = yield* AccountEffect.Service const accounts = yield* service.list() if (accounts.length === 0) return yield* println("Not logged in") @@ -98,7 +98,7 @@ interface OrgChoice { } const switchEffect = Effect.fn("switch")(function* () { - const service = yield* AccountService + const service = yield* AccountEffect.Service const groups = yield* service.orgsByAccount() if (groups.length === 0) return yield* println("Not logged in") @@ -129,7 +129,7 @@ const switchEffect = Effect.fn("switch")(function* () { }) const orgsEffect = Effect.fn("orgs")(function* () { - const service = yield* AccountService + const service = yield* AccountEffect.Service const groups = yield* service.orgsByAccount() if (groups.length === 0) return yield* println("No accounts found") diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index 3a1fb0cdf9..fa6c72c67e 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -3,11 +3,11 @@ import { FileService } from "@/file" import { FileTimeService } from "@/file/time" import { FileWatcherService } from "@/file/watcher" import { FormatService } from "@/format" -import { PermissionEffect } from "@/permission/service" +import { PermissionEffect } from "@/permission/effect" import { Instance } from "@/project/instance" import { VcsService } from "@/project/vcs" import { ProviderAuthService } from "@/provider/auth-service" -import { QuestionService } from "@/question/service" +import { QuestionEffect } from "@/question/effect" import { SkillService } from "@/skill/skill" import { SnapshotService } from "@/snapshot" import { InstanceContext } from "./instance-context" @@ -16,7 +16,7 @@ import { registerDisposer } from "./instance-registry" export { InstanceContext } from "./instance-context" export type InstanceServices = - | QuestionService + | QuestionEffect.Service | PermissionEffect.Service | ProviderAuthService | FileWatcherService @@ -36,7 +36,7 @@ export type InstanceServices = function lookup(_key: string) { const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current)) return Layer.mergeAll( - Layer.fresh(QuestionService.layer), + Layer.fresh(QuestionEffect.layer), Layer.fresh(PermissionEffect.layer), Layer.fresh(ProviderAuthService.layer), Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie), diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts index a55956bfd9..662a161119 100644 --- a/packages/opencode/src/effect/runtime.ts +++ b/packages/opencode/src/effect/runtime.ts @@ -1,6 +1,6 @@ import { Effect, Layer, ManagedRuntime } from "effect" -import { AccountService } from "@/account/service" -import { AuthService } from "@/auth/service" +import { AccountEffect } from "@/account/effect" +import { AuthEffect } from "@/auth/effect" import { Instances } from "@/effect/instances" import type { InstanceServices } from "@/effect/instances" import { TruncateEffect } from "@/tool/truncate-effect" @@ -8,10 +8,10 @@ import { Instance } from "@/project/instance" export const runtime = ManagedRuntime.make( Layer.mergeAll( - AccountService.defaultLayer, // + AccountEffect.defaultLayer, // TruncateEffect.defaultLayer, Instances.layer, - ).pipe(Layer.provideMerge(AuthService.defaultLayer)), + ).pipe(Layer.provideMerge(AuthEffect.defaultLayer)), ) export function runPromiseInstance(effect: Effect.Effect) { diff --git a/packages/opencode/src/permission/service.ts b/packages/opencode/src/permission/effect.ts similarity index 95% rename from packages/opencode/src/permission/service.ts rename to packages/opencode/src/permission/effect.ts index 4335aa4cd8..ec0e180f1e 100644 --- a/packages/opencode/src/permission/service.ts +++ b/packages/opencode/src/permission/effect.ts @@ -109,7 +109,7 @@ export namespace PermissionEffect { message: z.string().optional(), }) - export interface Api { + export interface Interface { readonly ask: (input: z.infer) => Effect.Effect readonly reply: (input: z.infer) => Effect.Effect readonly list: () => Effect.Effect @@ -129,7 +129,7 @@ export namespace PermissionEffect { return match ?? { action: "ask", permission, pattern: "*" } } - export class Service extends ServiceMap.Service()("@opencode/PermissionNext") {} + export class Service extends ServiceMap.Service()("@opencode/PermissionNext") {} export const layer = Layer.effect( Service, @@ -141,7 +141,7 @@ export namespace PermissionEffect { const pending = new Map() const approved: Ruleset = row?.data ?? [] - const ask = Effect.fn("PermissionService.ask")(function* (input: z.infer) { + const ask = Effect.fn("Permission.ask")(function* (input: z.infer) { const { ruleset, ...request } = input let needsAsk = false @@ -177,7 +177,7 @@ export namespace PermissionEffect { ) }) - const reply = Effect.fn("PermissionService.reply")(function* (input: z.infer) { + const reply = Effect.fn("Permission.reply")(function* (input: z.infer) { const existing = pending.get(input.requestID) if (!existing) return @@ -234,7 +234,7 @@ export namespace PermissionEffect { } }) - const list = Effect.fn("PermissionService.list")(function* () { + const list = Effect.fn("Permission.list")(function* () { return Array.from(pending.values(), (item) => item.info) }) diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 1349983542..5e9bac90d6 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -3,7 +3,7 @@ import { Config } from "@/config/config" import { fn } from "@/util/fn" import { Wildcard } from "@/util/wildcard" import os from "os" -import { PermissionEffect as S } from "./service" +import { PermissionEffect as S } from "./effect" export namespace PermissionNext { function expand(pattern: string): string { diff --git a/packages/opencode/src/provider/auth-service.ts b/packages/opencode/src/provider/auth-service.ts index 2e99859398..6ab696a770 100644 --- a/packages/opencode/src/provider/auth-service.ts +++ b/packages/opencode/src/provider/auth-service.ts @@ -1,6 +1,6 @@ import type { AuthOuathResult } from "@opencode-ai/plugin" import { NamedError } from "@opencode-ai/util/error" -import * as Auth from "@/auth/service" +import * as Auth from "@/auth/effect" import { ProviderID } from "./schema" import { Effect, Layer, Record, ServiceMap, Struct } from "effect" import { filter, fromEntries, map, pipe } from "remeda" @@ -44,7 +44,7 @@ export const OauthCodeMissing = NamedError.create( export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({})) export type ProviderAuthError = - | Auth.AuthServiceError + | Auth.AuthEffect.AuthServiceError | InstanceType | InstanceType | InstanceType @@ -67,7 +67,7 @@ export class ProviderAuthService extends ServiceMap.Service { const mod = await import("../plugin") return pipe( @@ -139,5 +139,5 @@ export class ProviderAuthService extends ServiceMap.Service + + export const Info = z + .object({ + question: z.string().describe("Complete question"), + header: z.string().describe("Very short label (max 30 chars)"), + options: z.array(Option).describe("Available choices"), + multiple: z.boolean().optional().describe("Allow selecting multiple choices"), + custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"), + }) + .meta({ ref: "QuestionInfo" }) + export type Info = z.infer + + export const Request = z + .object({ + id: QuestionID.zod, + sessionID: SessionID.zod, + questions: z.array(Info).describe("Questions to ask"), + tool: z + .object({ + messageID: MessageID.zod, + callID: z.string(), + }) + .optional(), + }) + .meta({ ref: "QuestionRequest" }) + export type Request = z.infer + + export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" }) + export type Answer = z.infer + + export const Reply = z.object({ + answers: z.array(Answer).describe("User answers in order of questions (each answer is an array of selected labels)"), + }) + export type Reply = z.infer + + export const Event = { + Asked: BusEvent.define("question.asked", Request), + Replied: BusEvent.define( + "question.replied", + z.object({ + sessionID: SessionID.zod, + requestID: QuestionID.zod, + answers: z.array(Answer), + }), + ), + Rejected: BusEvent.define( + "question.rejected", + z.object({ + sessionID: SessionID.zod, + requestID: QuestionID.zod, + }), + ), + } + + export class RejectedError extends Schema.TaggedErrorClass()("QuestionRejectedError", {}) { + override get message() { + return "The user dismissed this question" + } + } + + export type Error = RejectedError + + interface Pending { + info: Request + deferred: Deferred.Deferred + } + + export interface Interface { + readonly ask: (input: { + sessionID: SessionID + questions: Info[] + tool?: { messageID: MessageID; callID: string } + }) => Effect.Effect + readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect + readonly reject: (requestID: QuestionID) => Effect.Effect + readonly list: () => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/Question") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const pending = new Map() + + const ask = Effect.fn("Question.ask")(function* (input: { + sessionID: SessionID + questions: Info[] + tool?: { messageID: MessageID; callID: string } + }) { + const id = QuestionID.ascending() + log.info("asking", { id, questions: input.questions.length }) + + const deferred = yield* Deferred.make() + const info: Request = { + id, + sessionID: input.sessionID, + questions: input.questions, + tool: input.tool, + } + pending.set(id, { info, deferred }) + Bus.publish(Event.Asked, info) + + return yield* Effect.ensuring( + Deferred.await(deferred), + Effect.sync(() => { + pending.delete(id) + }), + ) + }) + + const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) { + const existing = pending.get(input.requestID) + if (!existing) { + log.warn("reply for unknown request", { requestID: input.requestID }) + return + } + pending.delete(input.requestID) + log.info("replied", { requestID: input.requestID, answers: input.answers }) + Bus.publish(Event.Replied, { + sessionID: existing.info.sessionID, + requestID: existing.info.id, + answers: input.answers, + }) + yield* Deferred.succeed(existing.deferred, input.answers) + }) + + const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) { + const existing = pending.get(requestID) + if (!existing) { + log.warn("reject for unknown request", { requestID }) + return + } + pending.delete(requestID) + log.info("rejected", { requestID }) + Bus.publish(Event.Rejected, { + sessionID: existing.info.sessionID, + requestID: existing.info.id, + }) + yield* Deferred.fail(existing.deferred, new RejectedError()) + }) + + const list = Effect.fn("Question.list")(function* () { + return Array.from(pending.values(), (x) => x.info) + }) + + return Service.of({ ask, reply, reject, list }) + }), + ) +} diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 7fffc0c877..b28f7b23c2 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -1,39 +1,39 @@ import { runPromiseInstance } from "@/effect/runtime" -import * as S from "./service" +import * as S from "./effect" import type { QuestionID } from "./schema" import type { SessionID, MessageID } from "@/session/schema" export namespace Question { - export const Option = S.Option - export type Option = S.Option - export const Info = S.Info - export type Info = S.Info - export const Request = S.Request - export type Request = S.Request - export const Answer = S.Answer - export type Answer = S.Answer - export const Reply = S.Reply - export type Reply = S.Reply - export const Event = S.Event - export const RejectedError = S.RejectedError + export const Option = S.QuestionEffect.Option + export type Option = S.QuestionEffect.Option + export const Info = S.QuestionEffect.Info + export type Info = S.QuestionEffect.Info + export const Request = S.QuestionEffect.Request + export type Request = S.QuestionEffect.Request + export const Answer = S.QuestionEffect.Answer + export type Answer = S.QuestionEffect.Answer + export const Reply = S.QuestionEffect.Reply + export type Reply = S.QuestionEffect.Reply + export const Event = S.QuestionEffect.Event + export const RejectedError = S.QuestionEffect.RejectedError export async function ask(input: { sessionID: SessionID questions: Info[] tool?: { messageID: MessageID; callID: string } }): Promise { - return runPromiseInstance(S.QuestionService.use((service) => service.ask(input))) + return runPromiseInstance(S.QuestionEffect.Service.use((service) => service.ask(input))) } export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise { - return runPromiseInstance(S.QuestionService.use((service) => service.reply(input))) + return runPromiseInstance(S.QuestionEffect.Service.use((service) => service.reply(input))) } export async function reject(requestID: QuestionID): Promise { - return runPromiseInstance(S.QuestionService.use((service) => service.reject(requestID))) + return runPromiseInstance(S.QuestionEffect.Service.use((service) => service.reject(requestID))) } export async function list(): Promise { - return runPromiseInstance(S.QuestionService.use((service) => service.list())) + return runPromiseInstance(S.QuestionEffect.Service.use((service) => service.list())) } } diff --git a/packages/opencode/src/question/service.ts b/packages/opencode/src/question/service.ts deleted file mode 100644 index 3df8286e6d..0000000000 --- a/packages/opencode/src/question/service.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" -import { Bus } from "@/bus" -import { BusEvent } from "@/bus/bus-event" -import { SessionID, MessageID } from "@/session/schema" -import { Log } from "@/util/log" -import z from "zod" -import { QuestionID } from "./schema" - -const log = Log.create({ service: "question" }) - -// --- Zod schemas (re-exported by facade) --- - -export const Option = z - .object({ - label: z.string().describe("Display text (1-5 words, concise)"), - description: z.string().describe("Explanation of choice"), - }) - .meta({ ref: "QuestionOption" }) -export type Option = z.infer - -export const Info = z - .object({ - question: z.string().describe("Complete question"), - header: z.string().describe("Very short label (max 30 chars)"), - options: z.array(Option).describe("Available choices"), - multiple: z.boolean().optional().describe("Allow selecting multiple choices"), - custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"), - }) - .meta({ ref: "QuestionInfo" }) -export type Info = z.infer - -export const Request = z - .object({ - id: QuestionID.zod, - sessionID: SessionID.zod, - questions: z.array(Info).describe("Questions to ask"), - tool: z - .object({ - messageID: MessageID.zod, - callID: z.string(), - }) - .optional(), - }) - .meta({ ref: "QuestionRequest" }) -export type Request = z.infer - -export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" }) -export type Answer = z.infer - -export const Reply = z.object({ - answers: z.array(Answer).describe("User answers in order of questions (each answer is an array of selected labels)"), -}) -export type Reply = z.infer - -export const Event = { - Asked: BusEvent.define("question.asked", Request), - Replied: BusEvent.define( - "question.replied", - z.object({ - sessionID: SessionID.zod, - requestID: QuestionID.zod, - answers: z.array(Answer), - }), - ), - Rejected: BusEvent.define( - "question.rejected", - z.object({ - sessionID: SessionID.zod, - requestID: QuestionID.zod, - }), - ), -} - -export class RejectedError extends Schema.TaggedErrorClass()("QuestionRejectedError", {}) { - override get message() { - return "The user dismissed this question" - } -} - -// --- Effect service --- - -interface PendingEntry { - info: Request - deferred: Deferred.Deferred -} - -export namespace QuestionService { - export interface Service { - readonly ask: (input: { - sessionID: SessionID - questions: Info[] - tool?: { messageID: MessageID; callID: string } - }) => Effect.Effect - readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect - readonly reject: (requestID: QuestionID) => Effect.Effect - readonly list: () => Effect.Effect - } -} - -export class QuestionService extends ServiceMap.Service()( - "@opencode/Question", -) { - static readonly layer = Layer.effect( - QuestionService, - Effect.gen(function* () { - const pending = new Map() - - const ask = Effect.fn("QuestionService.ask")(function* (input: { - sessionID: SessionID - questions: Info[] - tool?: { messageID: MessageID; callID: string } - }) { - const id = QuestionID.ascending() - log.info("asking", { id, questions: input.questions.length }) - - const deferred = yield* Deferred.make() - const info: Request = { - id, - sessionID: input.sessionID, - questions: input.questions, - tool: input.tool, - } - pending.set(id, { info, deferred }) - Bus.publish(Event.Asked, info) - - return yield* Effect.ensuring( - Deferred.await(deferred), - Effect.sync(() => { - pending.delete(id) - }), - ) - }) - - const reply = Effect.fn("QuestionService.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) { - const existing = pending.get(input.requestID) - if (!existing) { - log.warn("reply for unknown request", { requestID: input.requestID }) - return - } - pending.delete(input.requestID) - log.info("replied", { requestID: input.requestID, answers: input.answers }) - Bus.publish(Event.Replied, { - sessionID: existing.info.sessionID, - requestID: existing.info.id, - answers: input.answers, - }) - yield* Deferred.succeed(existing.deferred, input.answers) - }) - - const reject = Effect.fn("QuestionService.reject")(function* (requestID: QuestionID) { - const existing = pending.get(requestID) - if (!existing) { - log.warn("reject for unknown request", { requestID }) - return - } - pending.delete(requestID) - log.info("rejected", { requestID }) - Bus.publish(Event.Rejected, { - sessionID: existing.info.sessionID, - requestID: existing.info.id, - }) - yield* Deferred.fail(existing.deferred, new RejectedError()) - }) - - const list = Effect.fn("QuestionService.list")(function* () { - return Array.from(pending.values(), (x) => x.info) - }) - - return QuestionService.of({ ask, reply, reject, list }) - }), - ) -} diff --git a/packages/opencode/src/tool/truncate-effect.ts b/packages/opencode/src/tool/truncate-effect.ts index 4d0ed8168f..e72c3374b6 100644 --- a/packages/opencode/src/tool/truncate-effect.ts +++ b/packages/opencode/src/tool/truncate-effect.ts @@ -2,7 +2,7 @@ import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap } from "effect" import path from "path" import type { Agent } from "../agent/agent" -import { PermissionEffect } from "../permission/service" +import { PermissionEffect } from "../permission/effect" import { Identifier } from "../id/id" import { Log } from "../util/log" import { ToolID } from "./schema" diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts index ca244c2d94..09d773b13b 100644 --- a/packages/opencode/test/account/service.test.ts +++ b/packages/opencode/test/account/service.test.ts @@ -3,7 +3,7 @@ import { Duration, Effect, Layer, Option, Schema } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { AccountRepo } from "../../src/account/repo" -import { AccountService } from "../../src/account/service" +import { AccountEffect } from "../../src/account/effect" import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema" import { Database } from "../../src/storage/db" import { testEffect } from "../lib/effect" @@ -19,7 +19,7 @@ const truncate = Layer.effectDiscard( const it = testEffect(Layer.merge(AccountRepo.layer, truncate)) const live = (client: HttpClient.HttpClient) => - AccountService.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client))) + AccountEffect.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client))) const json = (req: Parameters[0], body: unknown, status = 200) => HttpClientResponse.fromWeb( @@ -77,7 +77,7 @@ it.effect("orgsByAccount groups orgs per account", () => }), ) - const rows = yield* AccountService.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client))) + const rows = yield* AccountEffect.Service.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client))) expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([ [AccountID.make("user-1"), [OrgID.make("org-1")]], @@ -118,7 +118,7 @@ it.effect("token refresh persists the new token", () => ), ) - const token = yield* AccountService.use((s) => s.token(id)).pipe(Effect.provide(live(client))) + const token = yield* AccountEffect.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client))) expect(Option.getOrThrow(token)).toBeDefined() expect(String(Option.getOrThrow(token))).toBe("at_new") @@ -161,7 +161,7 @@ it.effect("config sends the selected org header", () => }), ) - const cfg = yield* AccountService.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client))) + const cfg = yield* AccountEffect.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client))) expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 }) expect(seen).toEqual({ @@ -199,7 +199,7 @@ it.effect("poll stores the account and first org on success", () => ), ) - const res = yield* AccountService.use((s) => s.poll(login)).pipe(Effect.provide(live(client))) + const res = yield* AccountEffect.Service.use((s) => s.poll(login)).pipe(Effect.provide(live(client))) expect(res._tag).toBe("PollSuccess") if (res._tag === "PollSuccess") { diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 6e4fc1e9a4..c02a6838f2 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -5,7 +5,7 @@ import { Bus } from "../../src/bus" import { runtime } from "../../src/effect/runtime" import { Instances } from "../../src/effect/instances" import { PermissionNext } from "../../src/permission" -import * as S from "../../src/permission/service" +import * as S from "../../src/permission/effect" import { PermissionID } from "../../src/permission/schema" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture"