diff --git a/packages/opencode/script/seed-e2e.ts b/packages/opencode/script/seed-e2e.ts index fc3573548d3..f5bd7194f25 100644 --- a/packages/opencode/script/seed-e2e.ts +++ b/packages/opencode/script/seed-e2e.ts @@ -11,7 +11,6 @@ const seed = async () => { const { Instance } = await import("../src/project/instance") const { InstanceBootstrap } = await import("../src/project/bootstrap") const { Config } = await import("../src/config/config") - const { disposeRuntime } = await import("../src/effect/runtime") const { Session } = await import("../src/session") const { MessageID, PartID } = await import("../src/session/schema") const { Project } = await import("../src/project/project") @@ -55,7 +54,6 @@ const seed = async () => { }) } finally { await Instance.disposeAll().catch(() => {}) - await disposeRuntime().catch(() => {}) } } diff --git a/packages/opencode/specs/effect-migration.md b/packages/opencode/specs/effect-migration.md index 4f195917fde..80c906fcc82 100644 --- a/packages/opencode/specs/effect-migration.md +++ b/packages/opencode/specs/effect-migration.md @@ -4,18 +4,18 @@ Practical reference for new and migrated Effect code in `packages/opencode`. ## Choose scope -Use the shared runtime for process-wide services with one lifecycle for the whole app. +Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need per-directory state, per-instance cleanup, or project-bound background work. InstanceState uses a `ScopedCache` keyed by directory, so each open project gets its own copy of the state that is automatically cleaned up on disposal. -Use `src/effect/instances.ts` for services that are created per directory or need `InstanceContext`, per-project state, or per-instance cleanup. +Use `makeRunPromise` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. -- Shared runtime: config readers, stateless helpers, global clients -- Instance-scoped: watchers, per-project caches, session state, project-bound background work +- Global services (no per-directory state): Account, Auth, Installation, Truncate +- Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth -Rule of thumb: if two open directories should not share one copy of the service, it belongs in `Instances`. +Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`. ## Service shape -For a fully migrated module, use the public namespace directly: +Every service follows the same pattern — a single namespace with the service definition, layer, `runPromise`, and async facade functions: ```ts export namespace Foo { @@ -28,53 +28,52 @@ export namespace Foo { export const layer = Layer.effect( Service, Effect.gen(function* () { - return Service.of({ - get: Effect.fn("Foo.get")(function* (id) { - return yield* ... - }), + // For instance-scoped services: + const state = yield* InstanceState.make( + Effect.fn("Foo.state")(() => Effect.succeed({ ... })), + ) + + const get = Effect.fn("Foo.get")(function* (id: FooID) { + const s = yield* InstanceState.get(state) + // ... }) + + return Service.of({ get }) }), ) - export const defaultLayer = layer.pipe(Layer.provide(FooRepo.defaultLayer)) + // Optional: wire dependencies + export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer)) + + // Per-service runtime (inside the namespace) + const runPromise = makeRunPromise(Service, defaultLayer) + + // Async facade functions + export async function get(id: FooID) { + return runPromise((svc) => svc.get(id)) + } } ``` Rules: -- Keep `Interface`, `Service`, `layer`, and `defaultLayer` on the owning namespace -- Export `defaultLayer` only when wiring dependencies is useful -- Use the direct namespace form once the module is fully migrated - -## Temporary mixed-mode pattern +- Keep everything in one namespace, one file — no separate `service.ts` / `index.ts` split +- `runPromise` goes inside the namespace (not exported unless tests need it) +- Facade functions are plain `async function` — no `fn()` wrappers +- Use `Effect.fn("Namespace.method")` for all Effect functions (for tracing) +- No `Layer.fresh` — InstanceState handles per-directory isolation -Prefer a single namespace whenever possible. +## Schema → Zod interop -Use a `*Effect` namespace only when there is a real mixed-mode split, usually because a legacy boundary facade still exists or because merging everything immediately would create awkward cycles. +When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@/util/effect-zod`: ```ts -export namespace FooEffect { - export interface Interface { - readonly get: (id: FooID) => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@opencode/Foo") {} +import { zod } from "@/util/effect-zod" - export const layer = Layer.effect(...) -} -``` - -Then keep the old boundary thin: - -```ts -export namespace Foo { - export function get(id: FooID) { - return runtime.runPromise(FooEffect.Service.use((svc) => svc.get(id))) - } -} +export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union ``` -Remove the `Effect` suffix when the boundary split is gone. +See `Auth.ZodInfo` for the canonical example. ## Scheduled Tasks @@ -107,22 +106,23 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow ## Migration checklist -Done now: - -- [x] `AccountEffect` (mixed-mode) -- [x] `AuthEffect` (mixed-mode) -- [x] `TruncateEffect` (mixed-mode) -- [x] `Question` -- [x] `PermissionNext` -- [x] `ProviderAuth` -- [x] `FileWatcher` -- [x] `FileTime` -- [x] `Format` -- [x] `Vcs` -- [x] `Skill` -- [x] `Discovery` -- [x] `File` -- [x] `Snapshot` +Fully migrated (single namespace, InstanceState where needed, flattened facade): + +- [x] `Account` — `account/index.ts` +- [x] `Auth` — `auth/index.ts` (uses `zod()` helper for Schema→Zod interop) +- [x] `File` — `file/index.ts` +- [x] `FileTime` — `file/time.ts` +- [x] `FileWatcher` — `file/watcher.ts` +- [x] `Format` — `format/index.ts` +- [x] `Installation` — `installation/index.ts` +- [x] `Permission` — `permission/index.ts` +- [x] `ProviderAuth` — `provider/auth.ts` +- [x] `Question` — `question/index.ts` +- [x] `Skill` — `skill/index.ts` +- [x] `Snapshot` — `snapshot/index.ts` +- [x] `Truncate` — `tool/truncate.ts` +- [x] `Vcs` — `project/vcs.ts` +- [x] `Discovery` — `skill/discovery.ts` Still open and likely worth migrating: @@ -130,7 +130,6 @@ Still open and likely worth migrating: - [ ] `ToolRegistry` - [ ] `Pty` - [ ] `Worktree` -- [ ] `Installation` - [ ] `Bus` - [ ] `Command` - [ ] `Config` diff --git a/packages/opencode/src/account/effect.ts b/packages/opencode/src/account/effect.ts deleted file mode 100644 index 8686ef42a99..00000000000 --- a/packages/opencode/src/account/effect.ts +++ /dev/null @@ -1,380 +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, - AccountID, - DeviceCode, - Info, - RefreshToken, - AccountServiceError, - Login, - Org, - OrgID, - PollDenied, - PollError, - PollExpired, - PollPending, - type PollResult, - PollSlow, - PollSuccess, - UserCode, -} from "./schema" - -export { - AccountID, - type AccountError, - AccountRepoError, - AccountServiceError, - AccessToken, - RefreshToken, - DeviceCode, - UserCode, - Info, - Org, - OrgID, - Login, - PollSuccess, - PollPending, - PollSlow, - PollExpired, - PollDenied, - PollError, - PollResult, -} from "./schema" - -export type AccountOrgs = { - account: Info - 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 Account { - export interface Interface { - 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 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(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"), - ) - - const executeEffect = (request: Effect.Effect) => - request.pipe( - Effect.flatMap((req) => http.execute(req)), - mapAccountServiceError("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: 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("Account.token")((accountID: AccountID) => - resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.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 error of errors) { - yield* Effect.logWarning("failed to fetch orgs for account").pipe( - Effect.annotateLogs({ error: String(error) }), - ) - } - return results - }) - - const orgs = Effect.fn("Account.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("Account.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("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: 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("Account.poll")(function* (input: Login) { - const response = yield* executeEffect( - 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 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 753b80c5f1e..0a8d3687a35 100644 --- a/packages/opencode/src/account/index.ts +++ b/packages/opencode/src/account/index.ts @@ -1,34 +1,397 @@ -import { Effect, Option } from "effect" +import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" -import { Account as S, type AccountError, type AccessToken, AccountID, Info as Model, OrgID } from "./effect" +import { makeRunPromise } from "@/effect/run-service" +import { withTransientReadRetry } from "@/util/effect-http-client" +import { AccountRepo, type AccountRow } from "./repo" +import { + type AccountError, + AccessToken, + AccountID, + DeviceCode, + Info, + RefreshToken, + AccountServiceError, + Login, + Org, + OrgID, + PollDenied, + PollError, + PollExpired, + PollPending, + type PollResult, + PollSlow, + PollSuccess, + UserCode, +} from "./schema" -export { AccessToken, AccountID, OrgID } from "./effect" +export { + AccountID, + type AccountError, + AccountRepoError, + AccountServiceError, + AccessToken, + RefreshToken, + DeviceCode, + UserCode, + Info, + Org, + OrgID, + Login, + PollSuccess, + PollPending, + PollSlow, + PollExpired, + PollDenied, + PollError, + PollResult, +} from "./schema" -import { runtime } from "@/effect/runtime" - -function runSync(f: (service: S.Interface) => Effect.Effect) { - return runtime.runSync(S.Service.use(f)) +export type AccountOrgs = { + account: Info + orgs: readonly Org[] } -function runPromise(f: (service: S.Interface) => Effect.Effect) { - return runtime.runPromise(S.Service.use(f)) +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 Account { - export const Info = Model - export type Info = Model + export interface Interface { + 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 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(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"), + ) + + const executeEffect = (request: Effect.Effect) => + request.pipe( + Effect.flatMap((req) => http.execute(req)), + mapAccountServiceError("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: 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("Account.token")((accountID: AccountID) => + resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.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 error of errors) { + yield* Effect.logWarning("failed to fetch orgs for account").pipe( + Effect.annotateLogs({ error: String(error) }), + ) + } + return results + }) + + const orgs = Effect.fn("Account.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("Account.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("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: 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("Account.poll")(function* (input: Login) { + const response = yield* executeEffect( + 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 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)) + + export const runPromise = makeRunPromise(Service, defaultLayer) - export function active(): Info | undefined { - return Option.getOrUndefined(runSync((service) => service.active())) + export async function active(): Promise { + return Option.getOrUndefined(await runPromise((service) => service.active())) } export async function config(accountID: AccountID, orgID: OrgID): Promise | undefined> { - const config = await runPromise((service) => service.config(accountID, orgID)) - return Option.getOrUndefined(config) + const cfg = await runPromise((service) => service.config(accountID, orgID)) + return Option.getOrUndefined(cfg) } export async function token(accountID: AccountID): Promise { - const token = await runPromise((service) => service.token(accountID)) - return Option.getOrUndefined(token) + const t = await runPromise((service) => service.token(accountID)) + return Option.getOrUndefined(t) } } diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 5a629c73e12..30d09861447 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -14,7 +14,7 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt" import PROMPT_EXPLORE from "./prompt/explore.txt" import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_TITLE from "./prompt/title.txt" -import { Permission as PermissionNext } from "@/permission/service" +import { Permission } from "@/permission" import { mergeDeep, pipe, sortBy, values } from "remeda" import { Global } from "@/global" import path from "path" @@ -32,7 +32,7 @@ export namespace Agent { topP: z.number().optional(), temperature: z.number().optional(), color: z.string().optional(), - permission: PermissionNext.Ruleset, + permission: Permission.Ruleset, model: z .object({ modelID: ModelID.zod, @@ -54,7 +54,7 @@ export namespace Agent { const skillDirs = await Skill.dirs() const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))] - const defaults = PermissionNext.fromConfig({ + const defaults = Permission.fromConfig({ "*": "allow", doom_loop: "ask", external_directory: { @@ -72,16 +72,16 @@ export namespace Agent { "*.env.example": "allow", }, }) - const user = PermissionNext.fromConfig(cfg.permission ?? {}) + const user = Permission.fromConfig(cfg.permission ?? {}) const result: Record = { build: { name: "build", description: "The default agent. Executes tools based on configured permissions.", options: {}, - permission: PermissionNext.merge( + permission: Permission.merge( defaults, - PermissionNext.fromConfig({ + Permission.fromConfig({ question: "allow", plan_enter: "allow", }), @@ -94,9 +94,9 @@ export namespace Agent { name: "plan", description: "Plan mode. Disallows all edit tools.", options: {}, - permission: PermissionNext.merge( + permission: Permission.merge( defaults, - PermissionNext.fromConfig({ + Permission.fromConfig({ question: "allow", plan_exit: "allow", external_directory: { @@ -116,9 +116,9 @@ export namespace Agent { general: { name: "general", description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`, - permission: PermissionNext.merge( + permission: Permission.merge( defaults, - PermissionNext.fromConfig({ + Permission.fromConfig({ todoread: "deny", todowrite: "deny", }), @@ -130,9 +130,9 @@ export namespace Agent { }, explore: { name: "explore", - permission: PermissionNext.merge( + permission: Permission.merge( defaults, - PermissionNext.fromConfig({ + Permission.fromConfig({ "*": "deny", grep: "allow", glob: "allow", @@ -161,9 +161,9 @@ export namespace Agent { native: true, hidden: true, prompt: PROMPT_COMPACTION, - permission: PermissionNext.merge( + permission: Permission.merge( defaults, - PermissionNext.fromConfig({ + Permission.fromConfig({ "*": "deny", }), user, @@ -177,9 +177,9 @@ export namespace Agent { native: true, hidden: true, temperature: 0.5, - permission: PermissionNext.merge( + permission: Permission.merge( defaults, - PermissionNext.fromConfig({ + Permission.fromConfig({ "*": "deny", }), user, @@ -192,9 +192,9 @@ export namespace Agent { options: {}, native: true, hidden: true, - permission: PermissionNext.merge( + permission: Permission.merge( defaults, - PermissionNext.fromConfig({ + Permission.fromConfig({ "*": "deny", }), user, @@ -213,7 +213,7 @@ export namespace Agent { item = result[key] = { name: key, mode: "all", - permission: PermissionNext.merge(defaults, user), + permission: Permission.merge(defaults, user), options: {}, native: false, } @@ -229,7 +229,7 @@ export namespace Agent { item.name = value.name ?? item.name item.steps = value.steps ?? item.steps item.options = mergeDeep(item.options, value.options ?? {}) - item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {})) + item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) } // Ensure Truncate.GLOB is allowed unless explicitly configured @@ -242,9 +242,9 @@ export namespace Agent { }) if (explicit) continue - result[name].permission = PermissionNext.merge( + result[name].permission = Permission.merge( result[name].permission, - PermissionNext.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }), + Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }), ) } diff --git a/packages/opencode/src/auth/effect.ts b/packages/opencode/src/auth/effect.ts deleted file mode 100644 index 14a97080792..00000000000 --- a/packages/opencode/src/auth/effect.ts +++ /dev/null @@ -1,94 +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 AuthError extends Schema.TaggedErrorClass()("AuthError", { - message: Schema.String, - cause: Schema.optional(Schema.Defect), -}) {} - -const file = path.join(Global.Path.data, "auth.json") - -const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause }) - -export namespace Auth { - export interface Interface { - readonly get: (providerID: string) => Effect.Effect - readonly all: () => Effect.Effect, AuthError> - 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 }) - }), - ) -} diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 411d9dccc07..c50040f1d77 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,43 +1,101 @@ -import { Effect } from "effect" -import z from "zod" -import { runtime } from "@/effect/runtime" -import * as S from "./effect" +import path from "path" +import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect" +import { makeRunPromise } from "@/effect/run-service" +import { zod } from "@/util/effect-zod" +import { Global } from "../global" +import { Filesystem } from "../util/filesystem" -export { OAUTH_DUMMY_KEY } from "./effect" +export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" -function runPromise(f: (service: S.Auth.Interface) => Effect.Effect) { - return runtime.runPromise(S.Auth.Service.use(f)) -} +const file = path.join(Global.Path.data, "auth.json") + +const fail = (message: string) => (cause: unknown) => new Auth.AuthError({ message, cause }) 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 + 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, + }) {} + + const _Info = Schema.Union([Oauth, Api, WellKnown]) + export const Info = Object.assign(_Info, { zod: zod(_Info) }) + export type Info = Schema.Schema.Type + + export class AuthError extends Schema.TaggedErrorClass()("AuthError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), + }) {} + + export interface Interface { + readonly get: (providerID: string) => Effect.Effect + readonly all: () => Effect.Effect, AuthError> + 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 }) + }), + ) + + const runPromise = makeRunPromise(Service, layer) export async function get(providerID: string) { return runPromise((service) => service.get(providerID)) diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts index fb702c95a56..fe8747bce75 100644 --- a/packages/opencode/src/cli/cmd/account.ts +++ b/packages/opencode/src/cli/cmd/account.ts @@ -1,8 +1,7 @@ import { cmd } from "./cmd" import { Duration, Effect, Match, Option } from "effect" import { UI } from "../ui" -import { runtime } from "@/effect/runtime" -import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account/effect" +import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account" import { type AccountError } from "@/account/schema" import * as Prompt from "../effect/prompt" import open from "open" @@ -160,7 +159,7 @@ export const LoginCommand = cmd({ }), async handler(args) { UI.empty() - await runtime.runPromise(loginEffect(args.url)) + await Account.runPromise((_svc) => loginEffect(args.url)) }, }) @@ -174,7 +173,7 @@ export const LogoutCommand = cmd({ }), async handler(args) { UI.empty() - await runtime.runPromise(logoutEffect(args.email)) + await Account.runPromise((_svc) => logoutEffect(args.email)) }, }) @@ -183,7 +182,7 @@ export const SwitchCommand = cmd({ describe: false, async handler() { UI.empty() - await runtime.runPromise(switchEffect()) + await Account.runPromise((_svc) => switchEffect()) }, }) @@ -192,7 +191,7 @@ export const OrgsCommand = cmd({ describe: false, async handler() { UI.empty() - await runtime.runPromise(orgsEffect()) + await Account.runPromise((_svc) => orgsEffect()) }, }) diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index f33dcc55824..7f451e98c02 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -7,7 +7,7 @@ import type { MessageV2 } from "../../../session/message-v2" import { MessageID, PartID } from "../../../session/schema" import { ToolRegistry } from "../../../tool/registry" import { Instance } from "../../../project/instance" -import { PermissionNext } from "../../../permission" +import { Permission } from "../../../permission" import { iife } from "../../../util/iife" import { bootstrap } from "../../bootstrap" import { cmd } from "../cmd" @@ -75,7 +75,7 @@ async function getAvailableTools(agent: Agent.Info) { } async function resolveTools(agent: Agent.Info, availableTools: Awaited>) { - const disabled = PermissionNext.disabled( + const disabled = Permission.disabled( availableTools.map((tool) => tool.id), agent.permission, ) @@ -145,7 +145,7 @@ async function createToolContext(agent: Agent.Info) { } await Session.updateMessage(message) - const ruleset = PermissionNext.merge(agent.permission, session.permission ?? []) + const ruleset = Permission.merge(agent.permission, session.permission ?? []) return { sessionID: session.id, @@ -155,11 +155,11 @@ async function createToolContext(agent: Agent.Info) { abort: new AbortController().signal, messages: [], metadata: () => {}, - async ask(req: Omit) { + async ask(req: Omit) { for (const pattern of req.patterns) { - const rule = PermissionNext.evaluate(req.permission, pattern, ruleset) + const rule = Permission.evaluate(req.permission, pattern, ruleset) if (rule.action === "deny") { - throw new PermissionNext.DeniedError({ ruleset }) + throw new Permission.DeniedError({ ruleset }) } } }, diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 85b5689daa1..0aeb864e867 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -11,7 +11,7 @@ import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart import { Server } from "../../server/server" import { Provider } from "../../provider/provider" import { Agent } from "../../agent/agent" -import { PermissionNext } from "../../permission" +import { Permission } from "../../permission" import { Tool } from "../../tool/tool" import { GlobTool } from "../../tool/glob" import { GrepTool } from "../../tool/grep" @@ -354,7 +354,7 @@ export const RunCommand = cmd({ process.exit(1) } - const rules: PermissionNext.Ruleset = [ + const rules: Permission.Ruleset = [ { permission: "question", action: "deny", diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index d0698773472..3b296a927aa 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -22,7 +22,7 @@ import { createStore, produce, reconcile } from "solid-js/store" import { useSDK } from "@tui/context/sdk" import { Binary } from "@opencode-ai/util/binary" import { createSimpleContext } from "./helper" -import type { Snapshot } from "@/snapshot/service" +import type { Snapshot } from "@/snapshot" import { useExit } from "./exit" import { useArgs } from "./args" import { batch, onMount } from "solid-js" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 47afdfd7d0f..c464fcb64ab 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -177,7 +177,7 @@ export namespace Config { log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") } - const active = Account.active() + const active = await Account.active() if (active?.active_org_id) { try { const [config, token] = await Promise.all([ diff --git a/packages/opencode/src/effect/instance-state.ts b/packages/opencode/src/effect/instance-state.ts new file mode 100644 index 00000000000..fe3339ee689 --- /dev/null +++ b/packages/opencode/src/effect/instance-state.ts @@ -0,0 +1,47 @@ +import { Effect, ScopedCache, Scope } from "effect" +import { Instance, type Shape } from "@/project/instance" +import { registerDisposer } from "./instance-registry" + +const TypeId = "~opencode/InstanceState" + +export interface InstanceState { + readonly [TypeId]: typeof TypeId + readonly cache: ScopedCache.ScopedCache +} + +export namespace InstanceState { + export const make = ( + init: (ctx: Shape) => Effect.Effect, + ): Effect.Effect>, never, R | Scope.Scope> => + Effect.gen(function* () { + const cache = yield* ScopedCache.make({ + capacity: Number.POSITIVE_INFINITY, + lookup: () => init(Instance.current), + }) + + const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory))) + yield* Effect.addFinalizer(() => Effect.sync(off)) + + return { + [TypeId]: TypeId, + cache, + } + }) + + export const get = (self: InstanceState) => + Effect.suspend(() => ScopedCache.get(self.cache, Instance.directory)) + + export const use = (self: InstanceState, select: (value: A) => B) => + Effect.map(get(self), select) + + export const useEffect = ( + self: InstanceState, + select: (value: A) => Effect.Effect, + ) => Effect.flatMap(get(self), select) + + export const has = (self: InstanceState) => + Effect.suspend(() => ScopedCache.has(self.cache, Instance.directory)) + + export const invalidate = (self: InstanceState) => + Effect.suspend(() => ScopedCache.invalidate(self.cache, Instance.directory)) +} diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts deleted file mode 100644 index 6fcfddb24f5..00000000000 --- a/packages/opencode/src/effect/instances.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Effect, Layer, LayerMap, ServiceMap } from "effect" -import { File } from "@/file/service" -import { FileTime } from "@/file/time-service" -import { FileWatcher } from "@/file/watcher" -import { Format } from "@/format/service" -import { Permission } from "@/permission/service" -import { Instance } from "@/project/instance" -import { Vcs } from "@/project/vcs" -import { ProviderAuth } from "@/provider/auth-service" -import { Question } from "@/question/service" -import { Skill } from "@/skill/service" -import { Snapshot } from "@/snapshot/service" -import { InstanceContext } from "./instance-context" -import { registerDisposer } from "./instance-registry" - -export { InstanceContext } from "./instance-context" - -export type InstanceServices = - | Question.Service - | Permission.Service - | ProviderAuth.Service - | FileWatcher.Service - | Vcs.Service - | FileTime.Service - | Format.Service - | File.Service - | Skill.Service - | Snapshot.Service - -// NOTE: LayerMap only passes the key (directory string) to lookup, but we need -// the full instance context (directory, worktree, project). We read from the -// legacy Instance ALS here, which is safe because lookup is only triggered via -// runPromiseInstance -> Instances.get, which always runs inside Instance.provide. -// This should go away once the old Instance type is removed and lookup can load -// the full context directly. -function lookup(_key: string) { - const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current)) - return Layer.mergeAll( - Question.layer, - Permission.layer, - ProviderAuth.defaultLayer, - FileWatcher.layer, - Vcs.layer, - FileTime.layer, - Format.layer, - File.layer, - Skill.defaultLayer, - Snapshot.defaultLayer, - ).pipe(Layer.provide(ctx)) -} - -export class Instances extends ServiceMap.Service>()( - "opencode/Instances", -) { - static readonly layer = Layer.effect( - Instances, - Effect.gen(function* () { - const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity }) - const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory))) - yield* Effect.addFinalizer(() => Effect.sync(unregister)) - return Instances.of(layerMap) - }), - ) - - static get(directory: string): Layer.Layer { - return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory)))) - } -} diff --git a/packages/opencode/src/effect/run-service.ts b/packages/opencode/src/effect/run-service.ts new file mode 100644 index 00000000000..226c276ead1 --- /dev/null +++ b/packages/opencode/src/effect/run-service.ts @@ -0,0 +1,13 @@ +import { Effect, Layer, ManagedRuntime } from "effect" +import * as ServiceMap from "effect/ServiceMap" + +export const memoMap = Layer.makeMemoMapUnsafe() + +export function makeRunPromise(service: ServiceMap.Service, layer: Layer.Layer) { + let rt: ManagedRuntime.ManagedRuntime | undefined + + return (fn: (svc: S) => Effect.Effect, options?: Effect.RunOptions) => { + rt ??= ManagedRuntime.make(layer, { memoMap }) + return rt.runPromise(service.use(fn), options) + } +} diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts deleted file mode 100644 index e6f1f326262..00000000000 --- a/packages/opencode/src/effect/runtime.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Effect, Layer, ManagedRuntime } from "effect" -import { Account } from "@/account/effect" -import { Auth } from "@/auth/effect" -import { Instances } from "@/effect/instances" -import type { InstanceServices } from "@/effect/instances" -import { Installation } from "@/installation" -import { Truncate } from "@/tool/truncate-effect" -import { Instance } from "@/project/instance" - -export const runtime = ManagedRuntime.make( - Layer.mergeAll( - Account.defaultLayer, // - Installation.defaultLayer, - Truncate.defaultLayer, - Instances.layer, - ).pipe(Layer.provideMerge(Auth.layer)), -) - -export function runPromiseInstance(effect: Effect.Effect) { - return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory)))) -} - -export function disposeRuntime() { - return runtime.dispose() -} diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 35a5b5e204e..23c77e7bf71 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -1,40 +1,712 @@ -import { runPromiseInstance } from "@/effect/runtime" -import { File as S } from "./service" +import { BusEvent } from "@/bus/bus-event" +import { InstanceState } from "@/effect/instance-state" +import { makeRunPromise } from "@/effect/run-service" +import { git } from "@/util/git" +import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect" +import { formatPatch, structuredPatch } from "diff" +import fs from "fs" +import fuzzysort from "fuzzysort" +import ignore from "ignore" +import path from "path" +import z from "zod" +import { Global } from "../global" +import { Instance } from "../project/instance" +import { Filesystem } from "../util/filesystem" +import { Log } from "../util/log" +import { Protected } from "./protected" +import { Ripgrep } from "./ripgrep" export namespace File { - export const Info = S.Info - export type Info = S.Info + export const Info = z + .object({ + path: z.string(), + added: z.number().int(), + removed: z.number().int(), + status: z.enum(["added", "deleted", "modified"]), + }) + .meta({ + ref: "File", + }) - export const Node = S.Node - export type Node = S.Node + export type Info = z.infer - export const Content = S.Content - export type Content = S.Content + export const Node = z + .object({ + name: z.string(), + path: z.string(), + absolute: z.string(), + type: z.enum(["file", "directory"]), + ignored: z.boolean(), + }) + .meta({ + ref: "FileNode", + }) + export type Node = z.infer - export const Event = S.Event + export const Content = z + .object({ + type: z.enum(["text", "binary"]), + content: z.string(), + diff: z.string().optional(), + patch: z + .object({ + oldFileName: z.string(), + newFileName: z.string(), + oldHeader: z.string().optional(), + newHeader: z.string().optional(), + hunks: z.array( + z.object({ + oldStart: z.number(), + oldLines: z.number(), + newStart: z.number(), + newLines: z.number(), + lines: z.array(z.string()), + }), + ), + index: z.string().optional(), + }) + .optional(), + encoding: z.literal("base64").optional(), + mimeType: z.string().optional(), + }) + .meta({ + ref: "FileContent", + }) + export type Content = z.infer - export type Interface = S.Interface + export const Event = { + Edited: BusEvent.define( + "file.edited", + z.object({ + file: z.string(), + }), + ), + } + + const log = Log.create({ service: "file" }) + + const binary = new Set([ + "exe", + "dll", + "pdb", + "bin", + "so", + "dylib", + "o", + "a", + "lib", + "wav", + "mp3", + "ogg", + "oga", + "ogv", + "ogx", + "flac", + "aac", + "wma", + "m4a", + "weba", + "mp4", + "avi", + "mov", + "wmv", + "flv", + "webm", + "mkv", + "zip", + "tar", + "gz", + "gzip", + "bz", + "bz2", + "bzip", + "bzip2", + "7z", + "rar", + "xz", + "lz", + "z", + "pdf", + "doc", + "docx", + "ppt", + "pptx", + "xls", + "xlsx", + "dmg", + "iso", + "img", + "vmdk", + "ttf", + "otf", + "woff", + "woff2", + "eot", + "sqlite", + "db", + "mdb", + "apk", + "ipa", + "aab", + "xapk", + "app", + "pkg", + "deb", + "rpm", + "snap", + "flatpak", + "appimage", + "msi", + "msp", + "jar", + "war", + "ear", + "class", + "kotlin_module", + "dex", + "vdex", + "odex", + "oat", + "art", + "wasm", + "wat", + "bc", + "ll", + "s", + "ko", + "sys", + "drv", + "efi", + "rom", + "com", + ]) + + const image = new Set([ + "png", + "jpg", + "jpeg", + "gif", + "bmp", + "webp", + "ico", + "tif", + "tiff", + "svg", + "svgz", + "avif", + "apng", + "jxl", + "heic", + "heif", + "raw", + "cr2", + "nef", + "arw", + "dng", + "orf", + "raf", + "pef", + "x3f", + ]) + + const text = new Set([ + "ts", + "tsx", + "mts", + "cts", + "mtsx", + "ctsx", + "js", + "jsx", + "mjs", + "cjs", + "sh", + "bash", + "zsh", + "fish", + "ps1", + "psm1", + "cmd", + "bat", + "json", + "jsonc", + "json5", + "yaml", + "yml", + "toml", + "md", + "mdx", + "txt", + "xml", + "html", + "htm", + "css", + "scss", + "sass", + "less", + "graphql", + "gql", + "sql", + "ini", + "cfg", + "conf", + "env", + ]) + + const textName = new Set([ + "dockerfile", + "makefile", + ".gitignore", + ".gitattributes", + ".editorconfig", + ".npmrc", + ".nvmrc", + ".prettierrc", + ".eslintrc", + ]) + + const mime: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + bmp: "image/bmp", + webp: "image/webp", + ico: "image/x-icon", + tif: "image/tiff", + tiff: "image/tiff", + svg: "image/svg+xml", + svgz: "image/svg+xml", + avif: "image/avif", + apng: "image/apng", + jxl: "image/jxl", + heic: "image/heic", + heif: "image/heif", + } + + type Entry = { files: string[]; dirs: string[] } + + const ext = (file: string) => path.extname(file).toLowerCase().slice(1) + const name = (file: string) => path.basename(file).toLowerCase() + const isImageByExtension = (file: string) => image.has(ext(file)) + const isTextByExtension = (file: string) => text.has(ext(file)) + const isTextByName = (file: string) => textName.has(name(file)) + const isBinaryByExtension = (file: string) => binary.has(ext(file)) + const isImage = (mimeType: string) => mimeType.startsWith("image/") + const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file) + + function shouldEncode(mimeType: string) { + const type = mimeType.toLowerCase() + log.debug("shouldEncode", { type }) + if (!type) return false + if (type.startsWith("text/")) return false + if (type.includes("charset=")) return false + const top = type.split("/", 2)[0] + return ["image", "audio", "video", "font", "model", "multipart"].includes(top) + } + + const hidden = (item: string) => { + const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "") + return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1) + } + + const sortHiddenLast = (items: string[], prefer: boolean) => { + if (prefer) return items + const visible: string[] = [] + const hiddenItems: string[] = [] + for (const item of items) { + if (hidden(item)) hiddenItems.push(item) + else visible.push(item) + } + return [...visible, ...hiddenItems] + } + + interface State { + cache: Entry + fiber: Fiber.Fiber | undefined + } + + export interface Interface { + readonly init: () => Effect.Effect + readonly status: () => Effect.Effect + readonly read: (file: string) => Effect.Effect + readonly list: (dir?: string) => Effect.Effect + readonly search: (input: { + query: string + limit?: number + dirs?: boolean + type?: "file" | "directory" + }) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/File") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const state = yield* InstanceState.make( + Effect.fn("File.state")(() => + Effect.succeed({ + cache: { files: [], dirs: [] } as Entry, + fiber: undefined as Fiber.Fiber | undefined, + }), + ), + ) + + const scan = Effect.fn("File.scan")(function* () { + if (Instance.directory === path.parse(Instance.directory).root) return + const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global" + const next: Entry = { files: [], dirs: [] } + + yield* Effect.promise(async () => { + if (isGlobalHome) { + const dirs = new Set() + const protectedNames = Protected.names() + const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"]) + const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name) + const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name) + const top = await fs.promises + .readdir(Instance.directory, { withFileTypes: true }) + .catch(() => [] as fs.Dirent[]) + + for (const entry of top) { + if (!entry.isDirectory()) continue + if (shouldIgnoreName(entry.name)) continue + dirs.add(entry.name + "/") + + const base = path.join(Instance.directory, entry.name) + const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[]) + for (const child of children) { + if (!child.isDirectory()) continue + if (shouldIgnoreNested(child.name)) continue + dirs.add(entry.name + "/" + child.name + "/") + } + } + + next.dirs = Array.from(dirs).toSorted() + } else { + const seen = new Set() + for await (const file of Ripgrep.files({ cwd: Instance.directory })) { + next.files.push(file) + let current = file + while (true) { + const dir = path.dirname(current) + if (dir === ".") break + if (dir === current) break + current = dir + if (seen.has(dir)) continue + seen.add(dir) + next.dirs.push(dir + "/") + } + } + } + }) + + const s = yield* InstanceState.get(state) + s.cache = next + }) + + const scope = yield* Scope.Scope + + const ensure = Effect.fn("File.ensure")(function* () { + const s = yield* InstanceState.get(state) + if (!s.fiber) + s.fiber = yield* scan().pipe( + Effect.catchCause(() => Effect.void), + Effect.ensuring( + Effect.sync(() => { + s.fiber = undefined + }), + ), + Effect.forkIn(scope), + ) + yield* Fiber.join(s.fiber) + }) + + const init = Effect.fn("File.init")(function* () { + yield* ensure() + }) + + const status = Effect.fn("File.status")(function* () { + if (Instance.project.vcs !== "git") return [] + + return yield* Effect.promise(async () => { + const diffOutput = ( + await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], { + cwd: Instance.directory, + }) + ).text() + + const changed: File.Info[] = [] + + if (diffOutput.trim()) { + for (const line of diffOutput.trim().split("\n")) { + const [added, removed, file] = line.split("\t") + changed.push({ + path: file, + added: added === "-" ? 0 : parseInt(added, 10), + removed: removed === "-" ? 0 : parseInt(removed, 10), + status: "modified", + }) + } + } + + const untrackedOutput = ( + await git( + [ + "-c", + "core.fsmonitor=false", + "-c", + "core.quotepath=false", + "ls-files", + "--others", + "--exclude-standard", + ], + { + cwd: Instance.directory, + }, + ) + ).text() + + if (untrackedOutput.trim()) { + for (const file of untrackedOutput.trim().split("\n")) { + try { + const content = await Filesystem.readText(path.join(Instance.directory, file)) + changed.push({ + path: file, + added: content.split("\n").length, + removed: 0, + status: "added", + }) + } catch { + continue + } + } + } + + const deletedOutput = ( + await git( + [ + "-c", + "core.fsmonitor=false", + "-c", + "core.quotepath=false", + "diff", + "--name-only", + "--diff-filter=D", + "HEAD", + ], + { + cwd: Instance.directory, + }, + ) + ).text() + + if (deletedOutput.trim()) { + for (const file of deletedOutput.trim().split("\n")) { + changed.push({ + path: file, + added: 0, + removed: 0, + status: "deleted", + }) + } + } + + return changed.map((item) => { + const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path) + return { + ...item, + path: path.relative(Instance.directory, full), + } + }) + }) + }) + + const read = Effect.fn("File.read")(function* (file: string) { + return yield* Effect.promise(async (): Promise => { + using _ = log.time("read", { file }) + const full = path.join(Instance.directory, file) + + if (!Instance.containsPath(full)) { + throw new Error("Access denied: path escapes project directory") + } + + if (isImageByExtension(file)) { + if (await Filesystem.exists(full)) { + const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([])) + return { + type: "text", + content: buffer.toString("base64"), + mimeType: getImageMimeType(file), + encoding: "base64", + } + } + return { type: "text", content: "" } + } + + const knownText = isTextByExtension(file) || isTextByName(file) + + if (isBinaryByExtension(file) && !knownText) { + return { type: "binary", content: "" } + } + + if (!(await Filesystem.exists(full))) { + return { type: "text", content: "" } + } + + const mimeType = Filesystem.mimeType(full) + const encode = knownText ? false : shouldEncode(mimeType) + + if (encode && !isImage(mimeType)) { + return { type: "binary", content: "", mimeType } + } + + if (encode) { + const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([])) + return { + type: "text", + content: buffer.toString("base64"), + mimeType, + encoding: "base64", + } + } + + const content = (await Filesystem.readText(full).catch(() => "")).trim() + + if (Instance.project.vcs === "git") { + let diff = ( + await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory }) + ).text() + if (!diff.trim()) { + diff = ( + await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { + cwd: Instance.directory, + }) + ).text() + } + if (diff.trim()) { + const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text() + const patch = structuredPatch(file, file, original, content, "old", "new", { + context: Infinity, + ignoreWhitespace: true, + }) + return { + type: "text", + content, + patch, + diff: formatPatch(patch), + } + } + } + + return { type: "text", content } + }) + }) + + const list = Effect.fn("File.list")(function* (dir?: string) { + return yield* Effect.promise(async () => { + const exclude = [".git", ".DS_Store"] + let ignored = (_: string) => false + if (Instance.project.vcs === "git") { + const ig = ignore() + const gitignore = path.join(Instance.project.worktree, ".gitignore") + if (await Filesystem.exists(gitignore)) { + ig.add(await Filesystem.readText(gitignore)) + } + const ignoreFile = path.join(Instance.project.worktree, ".ignore") + if (await Filesystem.exists(ignoreFile)) { + ig.add(await Filesystem.readText(ignoreFile)) + } + ignored = ig.ignores.bind(ig) + } + + const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory + if (!Instance.containsPath(resolved)) { + throw new Error("Access denied: path escapes project directory") + } + + const nodes: File.Node[] = [] + for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) { + if (exclude.includes(entry.name)) continue + const absolute = path.join(resolved, entry.name) + const file = path.relative(Instance.directory, absolute) + const type = entry.isDirectory() ? "directory" : "file" + nodes.push({ + name: entry.name, + path: file, + absolute, + type, + ignored: ignored(type === "directory" ? file + "/" : file), + }) + } + + return nodes.sort((a, b) => { + if (a.type !== b.type) return a.type === "directory" ? -1 : 1 + return a.name.localeCompare(b.name) + }) + }) + }) + + const search = Effect.fn("File.search")(function* (input: { + query: string + limit?: number + dirs?: boolean + type?: "file" | "directory" + }) { + yield* ensure() + const { cache } = yield* InstanceState.get(state) + + return yield* Effect.promise(async () => { + const query = input.query.trim() + const limit = input.limit ?? 100 + const kind = input.type ?? (input.dirs === false ? "file" : "all") + log.info("search", { query, kind }) + + const result = cache + const preferHidden = query.startsWith(".") || query.includes("/.") + + if (!query) { + if (kind === "file") return result.files.slice(0, limit) + return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit) + } + + const items = + kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs] + + const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit + const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target) + const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted + + log.info("search", { query, kind, results: output.length }) + return output + }) + }) + + log.info("init") + return Service.of({ init, status, read, list, search }) + }), + ) - export const Service = S.Service - export const layer = S.layer + const runPromise = makeRunPromise(Service, layer) export function init() { - return runPromiseInstance(S.Service.use((svc) => svc.init())) + return runPromise((svc) => svc.init()) } export async function status() { - return runPromiseInstance(S.Service.use((svc) => svc.status())) + return runPromise((svc) => svc.status()) } export async function read(file: string): Promise { - return runPromiseInstance(S.Service.use((svc) => svc.read(file))) + return runPromise((svc) => svc.read(file)) } export async function list(dir?: string) { - return runPromiseInstance(S.Service.use((svc) => svc.list(dir))) + return runPromise((svc) => svc.list(dir)) } export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { - return runPromiseInstance(S.Service.use((svc) => svc.search(input))) + return runPromise((svc) => svc.search(input)) } } diff --git a/packages/opencode/src/file/service.ts b/packages/opencode/src/file/service.ts deleted file mode 100644 index d4f6b347f81..00000000000 --- a/packages/opencode/src/file/service.ts +++ /dev/null @@ -1,674 +0,0 @@ -import { BusEvent } from "@/bus/bus-event" -import { InstanceContext } from "@/effect/instance-context" -import { git } from "@/util/git" -import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect" -import { formatPatch, structuredPatch } from "diff" -import fs from "fs" -import fuzzysort from "fuzzysort" -import ignore from "ignore" -import path from "path" -import z from "zod" -import { Global } from "../global" -import { Instance } from "../project/instance" -import { Filesystem } from "../util/filesystem" -import { Log } from "../util/log" -import { Protected } from "./protected" -import { Ripgrep } from "./ripgrep" - -export namespace File { - export const Info = z - .object({ - path: z.string(), - added: z.number().int(), - removed: z.number().int(), - status: z.enum(["added", "deleted", "modified"]), - }) - .meta({ - ref: "File", - }) - - export type Info = z.infer - - export const Node = z - .object({ - name: z.string(), - path: z.string(), - absolute: z.string(), - type: z.enum(["file", "directory"]), - ignored: z.boolean(), - }) - .meta({ - ref: "FileNode", - }) - export type Node = z.infer - - export const Content = z - .object({ - type: z.enum(["text", "binary"]), - content: z.string(), - diff: z.string().optional(), - patch: z - .object({ - oldFileName: z.string(), - newFileName: z.string(), - oldHeader: z.string().optional(), - newHeader: z.string().optional(), - hunks: z.array( - z.object({ - oldStart: z.number(), - oldLines: z.number(), - newStart: z.number(), - newLines: z.number(), - lines: z.array(z.string()), - }), - ), - index: z.string().optional(), - }) - .optional(), - encoding: z.literal("base64").optional(), - mimeType: z.string().optional(), - }) - .meta({ - ref: "FileContent", - }) - export type Content = z.infer - - export const Event = { - Edited: BusEvent.define( - "file.edited", - z.object({ - file: z.string(), - }), - ), - } - - const log = Log.create({ service: "file" }) - - const binary = new Set([ - "exe", - "dll", - "pdb", - "bin", - "so", - "dylib", - "o", - "a", - "lib", - "wav", - "mp3", - "ogg", - "oga", - "ogv", - "ogx", - "flac", - "aac", - "wma", - "m4a", - "weba", - "mp4", - "avi", - "mov", - "wmv", - "flv", - "webm", - "mkv", - "zip", - "tar", - "gz", - "gzip", - "bz", - "bz2", - "bzip", - "bzip2", - "7z", - "rar", - "xz", - "lz", - "z", - "pdf", - "doc", - "docx", - "ppt", - "pptx", - "xls", - "xlsx", - "dmg", - "iso", - "img", - "vmdk", - "ttf", - "otf", - "woff", - "woff2", - "eot", - "sqlite", - "db", - "mdb", - "apk", - "ipa", - "aab", - "xapk", - "app", - "pkg", - "deb", - "rpm", - "snap", - "flatpak", - "appimage", - "msi", - "msp", - "jar", - "war", - "ear", - "class", - "kotlin_module", - "dex", - "vdex", - "odex", - "oat", - "art", - "wasm", - "wat", - "bc", - "ll", - "s", - "ko", - "sys", - "drv", - "efi", - "rom", - "com", - "cmd", - "ps1", - "sh", - "bash", - "zsh", - "fish", - ]) - - const image = new Set([ - "png", - "jpg", - "jpeg", - "gif", - "bmp", - "webp", - "ico", - "tif", - "tiff", - "svg", - "svgz", - "avif", - "apng", - "jxl", - "heic", - "heif", - "raw", - "cr2", - "nef", - "arw", - "dng", - "orf", - "raf", - "pef", - "x3f", - ]) - - const text = new Set([ - "ts", - "tsx", - "mts", - "cts", - "mtsx", - "ctsx", - "js", - "jsx", - "mjs", - "cjs", - "sh", - "bash", - "zsh", - "fish", - "ps1", - "psm1", - "cmd", - "bat", - "json", - "jsonc", - "json5", - "yaml", - "yml", - "toml", - "md", - "mdx", - "txt", - "xml", - "html", - "htm", - "css", - "scss", - "sass", - "less", - "graphql", - "gql", - "sql", - "ini", - "cfg", - "conf", - "env", - ]) - - const textName = new Set([ - "dockerfile", - "makefile", - ".gitignore", - ".gitattributes", - ".editorconfig", - ".npmrc", - ".nvmrc", - ".prettierrc", - ".eslintrc", - ]) - - const mime: Record = { - png: "image/png", - jpg: "image/jpeg", - jpeg: "image/jpeg", - gif: "image/gif", - bmp: "image/bmp", - webp: "image/webp", - ico: "image/x-icon", - tif: "image/tiff", - tiff: "image/tiff", - svg: "image/svg+xml", - svgz: "image/svg+xml", - avif: "image/avif", - apng: "image/apng", - jxl: "image/jxl", - heic: "image/heic", - heif: "image/heif", - } - - type Entry = { files: string[]; dirs: string[] } - - const ext = (file: string) => path.extname(file).toLowerCase().slice(1) - const name = (file: string) => path.basename(file).toLowerCase() - const isImageByExtension = (file: string) => image.has(ext(file)) - const isTextByExtension = (file: string) => text.has(ext(file)) - const isTextByName = (file: string) => textName.has(name(file)) - const isBinaryByExtension = (file: string) => binary.has(ext(file)) - const isImage = (mimeType: string) => mimeType.startsWith("image/") - const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file) - - function shouldEncode(mimeType: string) { - const type = mimeType.toLowerCase() - log.info("shouldEncode", { type }) - if (!type) return false - if (type.startsWith("text/")) return false - if (type.includes("charset=")) return false - const top = type.split("/", 2)[0] - return ["image", "audio", "video", "font", "model", "multipart"].includes(top) - } - - const hidden = (item: string) => { - const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "") - return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1) - } - - const sortHiddenLast = (items: string[], prefer: boolean) => { - if (prefer) return items - const visible: string[] = [] - const hiddenItems: string[] = [] - for (const item of items) { - if (hidden(item)) hiddenItems.push(item) - else visible.push(item) - } - return [...visible, ...hiddenItems] - } - - export interface Interface { - readonly init: () => Effect.Effect - readonly status: () => Effect.Effect - readonly read: (file: string) => Effect.Effect - readonly list: (dir?: string) => Effect.Effect - readonly search: (input: { - query: string - limit?: number - dirs?: boolean - type?: "file" | "directory" - }) => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@opencode/File") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const instance = yield* InstanceContext - let cache: Entry = { files: [], dirs: [] } - const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global" - - const scan = Effect.fn("File.scan")(function* () { - if (instance.directory === path.parse(instance.directory).root) return - const next: Entry = { files: [], dirs: [] } - - yield* Effect.promise(async () => { - if (isGlobalHome) { - const dirs = new Set() - const protectedNames = Protected.names() - const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"]) - const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name) - const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name) - const top = await fs.promises - .readdir(instance.directory, { withFileTypes: true }) - .catch(() => [] as fs.Dirent[]) - - for (const entry of top) { - if (!entry.isDirectory()) continue - if (shouldIgnoreName(entry.name)) continue - dirs.add(entry.name + "/") - - const base = path.join(instance.directory, entry.name) - const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[]) - for (const child of children) { - if (!child.isDirectory()) continue - if (shouldIgnoreNested(child.name)) continue - dirs.add(entry.name + "/" + child.name + "/") - } - } - - next.dirs = Array.from(dirs).toSorted() - } else { - const seen = new Set() - for await (const file of Ripgrep.files({ cwd: instance.directory })) { - next.files.push(file) - let current = file - while (true) { - const dir = path.dirname(current) - if (dir === ".") break - if (dir === current) break - current = dir - if (seen.has(dir)) continue - seen.add(dir) - next.dirs.push(dir + "/") - } - } - } - }) - - cache = next - }) - - const getFiles = () => cache - - const scope = yield* Scope.Scope - let fiber: Fiber.Fiber | undefined - - const init = Effect.fn("File.init")(function* () { - if (!fiber) { - fiber = yield* scan().pipe( - Effect.catchCause(() => Effect.void), - Effect.forkIn(scope), - ) - } - yield* Fiber.join(fiber) - }) - - const status = Effect.fn("File.status")(function* () { - if (instance.project.vcs !== "git") return [] - - return yield* Effect.promise(async () => { - const diffOutput = ( - await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], { - cwd: instance.directory, - }) - ).text() - - const changed: File.Info[] = [] - - if (diffOutput.trim()) { - for (const line of diffOutput.trim().split("\n")) { - const [added, removed, file] = line.split("\t") - changed.push({ - path: file, - added: added === "-" ? 0 : parseInt(added, 10), - removed: removed === "-" ? 0 : parseInt(removed, 10), - status: "modified", - }) - } - } - - const untrackedOutput = ( - await git( - [ - "-c", - "core.fsmonitor=false", - "-c", - "core.quotepath=false", - "ls-files", - "--others", - "--exclude-standard", - ], - { - cwd: instance.directory, - }, - ) - ).text() - - if (untrackedOutput.trim()) { - for (const file of untrackedOutput.trim().split("\n")) { - try { - const content = await Filesystem.readText(path.join(instance.directory, file)) - changed.push({ - path: file, - added: content.split("\n").length, - removed: 0, - status: "added", - }) - } catch { - continue - } - } - } - - const deletedOutput = ( - await git( - [ - "-c", - "core.fsmonitor=false", - "-c", - "core.quotepath=false", - "diff", - "--name-only", - "--diff-filter=D", - "HEAD", - ], - { - cwd: instance.directory, - }, - ) - ).text() - - if (deletedOutput.trim()) { - for (const file of deletedOutput.trim().split("\n")) { - changed.push({ - path: file, - added: 0, - removed: 0, - status: "deleted", - }) - } - } - - return changed.map((item) => { - const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path) - return { - ...item, - path: path.relative(instance.directory, full), - } - }) - }) - }) - - const read = Effect.fn("File.read")(function* (file: string) { - return yield* Effect.promise(async (): Promise => { - using _ = log.time("read", { file }) - const full = path.join(instance.directory, file) - - if (!Instance.containsPath(full)) { - throw new Error("Access denied: path escapes project directory") - } - - if (isImageByExtension(file)) { - if (await Filesystem.exists(full)) { - const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([])) - return { - type: "text", - content: buffer.toString("base64"), - mimeType: getImageMimeType(file), - encoding: "base64", - } - } - return { type: "text", content: "" } - } - - const knownText = isTextByExtension(file) || isTextByName(file) - - if (isBinaryByExtension(file) && !knownText) { - return { type: "binary", content: "" } - } - - if (!(await Filesystem.exists(full))) { - return { type: "text", content: "" } - } - - const mimeType = Filesystem.mimeType(full) - const encode = knownText ? false : shouldEncode(mimeType) - - if (encode && !isImage(mimeType)) { - return { type: "binary", content: "", mimeType } - } - - if (encode) { - const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([])) - return { - type: "text", - content: buffer.toString("base64"), - mimeType, - encoding: "base64", - } - } - - const content = (await Filesystem.readText(full).catch(() => "")).trim() - - if (instance.project.vcs === "git") { - let diff = ( - await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory }) - ).text() - if (!diff.trim()) { - diff = ( - await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { - cwd: instance.directory, - }) - ).text() - } - if (diff.trim()) { - const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text() - const patch = structuredPatch(file, file, original, content, "old", "new", { - context: Infinity, - ignoreWhitespace: true, - }) - return { - type: "text", - content, - patch, - diff: formatPatch(patch), - } - } - } - - return { type: "text", content } - }) - }) - - const list = Effect.fn("File.list")(function* (dir?: string) { - return yield* Effect.promise(async () => { - const exclude = [".git", ".DS_Store"] - let ignored = (_: string) => false - if (instance.project.vcs === "git") { - const ig = ignore() - const gitignore = path.join(instance.project.worktree, ".gitignore") - if (await Filesystem.exists(gitignore)) { - ig.add(await Filesystem.readText(gitignore)) - } - const ignoreFile = path.join(instance.project.worktree, ".ignore") - if (await Filesystem.exists(ignoreFile)) { - ig.add(await Filesystem.readText(ignoreFile)) - } - ignored = ig.ignores.bind(ig) - } - - const resolved = dir ? path.join(instance.directory, dir) : instance.directory - if (!Instance.containsPath(resolved)) { - throw new Error("Access denied: path escapes project directory") - } - - const nodes: File.Node[] = [] - for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) { - if (exclude.includes(entry.name)) continue - const absolute = path.join(resolved, entry.name) - const file = path.relative(instance.directory, absolute) - const type = entry.isDirectory() ? "directory" : "file" - nodes.push({ - name: entry.name, - path: file, - absolute, - type, - ignored: ignored(type === "directory" ? file + "/" : file), - }) - } - - return nodes.sort((a, b) => { - if (a.type !== b.type) return a.type === "directory" ? -1 : 1 - return a.name.localeCompare(b.name) - }) - }) - }) - - const search = Effect.fn("File.search")(function* (input: { - query: string - limit?: number - dirs?: boolean - type?: "file" | "directory" - }) { - return yield* Effect.promise(async () => { - const query = input.query.trim() - const limit = input.limit ?? 100 - const kind = input.type ?? (input.dirs === false ? "file" : "all") - log.info("search", { query, kind }) - - const result = getFiles() - const preferHidden = query.startsWith(".") || query.includes("/.") - - if (!query) { - if (kind === "file") return result.files.slice(0, limit) - return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit) - } - - const items = - kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs] - - const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit - const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target) - const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted - - log.info("search", { query, kind, results: output.length }) - return output - }) - }) - - log.info("init") - return Service.of({ init, status, read, list, search }) - }), - ).pipe(Layer.fresh) -} diff --git a/packages/opencode/src/file/time-service.ts b/packages/opencode/src/file/time-service.ts deleted file mode 100644 index a0fa8bfabf7..00000000000 --- a/packages/opencode/src/file/time-service.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect" -import { Flag } from "@/flag/flag" -import type { SessionID } from "@/session/schema" -import { Filesystem } from "../util/filesystem" -import { Log } from "../util/log" - -export namespace FileTime { - const log = Log.create({ service: "file.time" }) - - export type Stamp = { - readonly read: Date - readonly mtime: number | undefined - readonly ctime: number | undefined - readonly size: number | undefined - } - - const stamp = Effect.fnUntraced(function* (file: string) { - const stat = Filesystem.stat(file) - const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size - return { - read: yield* DateTime.nowAsDate, - mtime: stat?.mtime?.getTime(), - ctime: stat?.ctime?.getTime(), - size, - } - }) - - const session = (reads: Map>, sessionID: SessionID) => { - const value = reads.get(sessionID) - if (value) return value - - const next = new Map() - reads.set(sessionID, next) - return next - } - - export interface Interface { - readonly read: (sessionID: SessionID, file: string) => Effect.Effect - readonly get: (sessionID: SessionID, file: string) => Effect.Effect - readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect - readonly withLock: (filepath: string, fn: () => Promise) => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@opencode/FileTime") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK - const reads = new Map>() - const locks = new Map() - - const getLock = (filepath: string) => { - const lock = locks.get(filepath) - if (lock) return lock - - const next = Semaphore.makeUnsafe(1) - locks.set(filepath, next) - return next - } - - const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) { - log.info("read", { sessionID, file }) - session(reads, sessionID).set(file, yield* stamp(file)) - }) - - const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) { - return reads.get(sessionID)?.get(file)?.read - }) - - const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) { - if (disableCheck) return - - const time = reads.get(sessionID)?.get(filepath) - if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`) - - const next = yield* stamp(filepath) - const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size - if (!changed) return - - throw new Error( - `File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`, - ) - }) - - const withLock = Effect.fn("FileTime.withLock")(function* (filepath: string, fn: () => Promise) { - return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1)) - }) - - return Service.of({ read, get, assert, withLock }) - }), - ).pipe(Layer.orDie, Layer.fresh) -} diff --git a/packages/opencode/src/file/time.ts b/packages/opencode/src/file/time.ts index b6d572fe8bc..4962ef0c9ee 100644 --- a/packages/opencode/src/file/time.ts +++ b/packages/opencode/src/file/time.ts @@ -1,28 +1,128 @@ -import { runPromiseInstance } from "@/effect/runtime" +import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect" +import { InstanceState } from "@/effect/instance-state" +import { makeRunPromise } from "@/effect/run-service" +import { Flag } from "@/flag/flag" import type { SessionID } from "@/session/schema" -import { FileTime as S } from "./time-service" +import { Filesystem } from "../util/filesystem" +import { Log } from "../util/log" export namespace FileTime { - export type Stamp = S.Stamp + const log = Log.create({ service: "file.time" }) - export type Interface = S.Interface + export type Stamp = { + readonly read: Date + readonly mtime: number | undefined + readonly ctime: number | undefined + readonly size: number | undefined + } + + const stamp = Effect.fnUntraced(function* (file: string) { + const stat = Filesystem.stat(file) + const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size + return { + read: yield* DateTime.nowAsDate, + mtime: stat?.mtime?.getTime(), + ctime: stat?.ctime?.getTime(), + size, + } + }) + + const session = (reads: Map>, sessionID: SessionID) => { + const value = reads.get(sessionID) + if (value) return value + + const next = new Map() + reads.set(sessionID, next) + return next + } + + interface State { + reads: Map> + locks: Map + } + + export interface Interface { + readonly read: (sessionID: SessionID, file: string) => Effect.Effect + readonly get: (sessionID: SessionID, file: string) => Effect.Effect + readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect + readonly withLock: (filepath: string, fn: () => Promise) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/FileTime") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK + const state = yield* InstanceState.make( + Effect.fn("FileTime.state")(() => + Effect.succeed({ + reads: new Map>(), + locks: new Map(), + }), + ), + ) + + const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) { + const locks = (yield* InstanceState.get(state)).locks + const lock = locks.get(filepath) + if (lock) return lock + + const next = Semaphore.makeUnsafe(1) + locks.set(filepath, next) + return next + }) + + const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) { + const reads = (yield* InstanceState.get(state)).reads + log.info("read", { sessionID, file }) + session(reads, sessionID).set(file, yield* stamp(file)) + }) + + const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) { + const reads = (yield* InstanceState.get(state)).reads + return reads.get(sessionID)?.get(file)?.read + }) + + const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) { + if (disableCheck) return + + const reads = (yield* InstanceState.get(state)).reads + const time = reads.get(sessionID)?.get(filepath) + if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`) + + const next = yield* stamp(filepath) + const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size + if (!changed) return + + throw new Error( + `File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`, + ) + }) + + const withLock = Effect.fn("FileTime.withLock")(function* (filepath: string, fn: () => Promise) { + return yield* Effect.promise(fn).pipe((yield* getLock(filepath)).withPermits(1)) + }) + + return Service.of({ read, get, assert, withLock }) + }), + ).pipe(Layer.orDie) - export const Service = S.Service - export const layer = S.layer + const runPromise = makeRunPromise(Service, layer) export function read(sessionID: SessionID, file: string) { - return runPromiseInstance(S.Service.use((s) => s.read(sessionID, file))) + return runPromise((s) => s.read(sessionID, file)) } export function get(sessionID: SessionID, file: string) { - return runPromiseInstance(S.Service.use((s) => s.get(sessionID, file))) + return runPromise((s) => s.get(sessionID, file)) } export async function assert(sessionID: SessionID, filepath: string) { - return runPromiseInstance(S.Service.use((s) => s.assert(sessionID, filepath))) + return runPromise((s) => s.assert(sessionID, filepath)) } export async function withLock(filepath: string, fn: () => Promise): Promise { - return runPromiseInstance(S.Service.use((s) => s.withLock(filepath, fn))) + return runPromise((s) => s.withLock(filepath, fn)) } } diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 7e5f5f7be31..1b3fc8ab4f2 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -1,4 +1,4 @@ -import { Cause, Effect, Layer, ServiceMap } from "effect" +import { Cause, Effect, Layer, Scope, ServiceMap } from "effect" // @ts-ignore import { createWrapper } from "@parcel/watcher/wrapper" import type ParcelWatcher from "@parcel/watcher" @@ -7,7 +7,8 @@ import path from "path" import z from "zod" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" -import { InstanceContext } from "@/effect/instance-context" +import { InstanceState } from "@/effect/instance-state" +import { makeRunPromise } from "@/effect/run-service" import { Flag } from "@/flag/flag" import { Instance } from "@/project/instance" import { git } from "@/util/git" @@ -60,82 +61,107 @@ export namespace FileWatcher { export const hasNativeBinding = () => !!watcher() - export class Service extends ServiceMap.Service()("@opencode/FileWatcher") {} + export interface Interface { + readonly init: () => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/FileWatcher") {} export const layer = Layer.effect( Service, Effect.gen(function* () { - const instance = yield* InstanceContext - if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({}) - - log.info("init", { directory: instance.directory }) - - const backend = getBackend() - if (!backend) { - log.error("watcher backend not supported", { directory: instance.directory, platform: process.platform }) - return Service.of({}) - } - - const w = watcher() - if (!w) return Service.of({}) - - log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend }) - - const subs: ParcelWatcher.AsyncSubscription[] = [] - yield* Effect.addFinalizer(() => Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe())))) - - const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => { - if (err) return - for (const evt of evts) { - if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" }) - if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" }) - if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" }) - } - }) - - const subscribe = (dir: string, ignore: string[]) => { - const pending = w.subscribe(dir, cb, { ignore, backend }) - return Effect.gen(function* () { - const sub = yield* Effect.promise(() => pending) - subs.push(sub) - }).pipe( - Effect.timeout(SUBSCRIBE_TIMEOUT_MS), + const state = yield* InstanceState.make( + Effect.fn("FileWatcher.state")( + function* () { + if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return + + log.info("init", { directory: Instance.directory }) + + const backend = getBackend() + if (!backend) { + log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform }) + return + } + + const w = watcher() + if (!w) return + + log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend }) + + const subs: ParcelWatcher.AsyncSubscription[] = [] + yield* Effect.addFinalizer(() => + Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))), + ) + + const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => { + if (err) return + for (const evt of evts) { + if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" }) + if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" }) + if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" }) + } + }) + + const subscribe = (dir: string, ignore: string[]) => { + const pending = w.subscribe(dir, cb, { ignore, backend }) + return Effect.gen(function* () { + const sub = yield* Effect.promise(() => pending) + subs.push(sub) + }).pipe( + Effect.timeout(SUBSCRIBE_TIMEOUT_MS), + Effect.catchCause((cause) => { + log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) }) + pending.then((s) => s.unsubscribe()).catch(() => {}) + return Effect.void + }), + ) + } + + const cfg = yield* Effect.promise(() => Config.get()) + const cfgIgnores = cfg.watcher?.ignore ?? [] + + if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { + yield* subscribe(Instance.directory, [ + ...FileIgnore.PATTERNS, + ...cfgIgnores, + ...protecteds(Instance.directory), + ]) + } + + if (Instance.project.vcs === "git") { + const result = yield* Effect.promise(() => + git(["rev-parse", "--git-dir"], { + cwd: Instance.project.worktree, + }), + ) + const vcsDir = + result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined + if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { + const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter( + (entry) => entry !== "HEAD", + ) + yield* subscribe(vcsDir, ignore) + } + } + }, Effect.catchCause((cause) => { - log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) }) - pending.then((s) => s.unsubscribe()).catch(() => {}) + log.error("failed to init watcher service", { cause: Cause.pretty(cause) }) return Effect.void }), - ) - } + ), + ) - const cfg = yield* Effect.promise(() => Config.get()) - const cfgIgnores = cfg.watcher?.ignore ?? [] + return Service.of({ + init: Effect.fn("FileWatcher.init")(function* () { + yield* InstanceState.get(state) + }), + }) + }), + ) - if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { - yield* subscribe(instance.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(instance.directory)]) - } + const runPromise = makeRunPromise(Service, layer) - if (instance.project.vcs === "git") { - const result = yield* Effect.promise(() => - git(["rev-parse", "--git-dir"], { - cwd: instance.project.worktree, - }), - ) - const vcsDir = result.exitCode === 0 ? path.resolve(instance.project.worktree, result.text().trim()) : undefined - if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { - const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter( - (entry) => entry !== "HEAD", - ) - yield* subscribe(vcsDir, ignore) - } - } - - return Service.of({}) - }).pipe( - Effect.catchCause((cause) => { - log.error("failed to init watcher service", { cause: Cause.pretty(cause) }) - return Effect.succeed(Service.of({})) - }), - ), - ).pipe(Layer.orDie, Layer.fresh) + export function init() { + return runPromise((svc) => svc.init()) + } } diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index e4381c69b20..39e0630cfcc 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -1,16 +1,182 @@ -import { runPromiseInstance } from "@/effect/runtime" -import { Format as S } from "./service" +import { Effect, Layer, ServiceMap } from "effect" +import { InstanceState } from "@/effect/instance-state" +import { makeRunPromise } from "@/effect/run-service" +import path from "path" +import { mergeDeep } from "remeda" +import z from "zod" +import { Bus } from "../bus" +import { Config } from "../config/config" +import { File } from "../file" +import { Instance } from "../project/instance" +import { Process } from "../util/process" +import { Log } from "../util/log" +import * as Formatter from "./formatter" export namespace Format { - export const Status = S.Status - export type Status = S.Status + const log = Log.create({ service: "format" }) - export type Interface = S.Interface + export const Status = z + .object({ + name: z.string(), + extensions: z.string().array(), + enabled: z.boolean(), + }) + .meta({ + ref: "FormatterStatus", + }) + export type Status = z.infer - export const Service = S.Service - export const layer = S.layer + export interface Interface { + readonly init: () => Effect.Effect + readonly status: () => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/Format") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const state = yield* InstanceState.make( + Effect.fn("Format.state")(function* (_ctx) { + const enabled: Record = {} + const formatters: Record = {} + + const cfg = yield* Effect.promise(() => Config.get()) + + if (cfg.formatter !== false) { + for (const item of Object.values(Formatter)) { + formatters[item.name] = item + } + for (const [name, item] of Object.entries(cfg.formatter ?? {})) { + if (item.disabled) { + delete formatters[name] + continue + } + const info = mergeDeep(formatters[name] ?? {}, { + command: [], + extensions: [], + ...item, + }) + + if (info.command.length === 0) continue + + formatters[name] = { + ...info, + name, + enabled: async () => true, + } + } + } else { + log.info("all formatters are disabled") + } + + async function isEnabled(item: Formatter.Info) { + let status = enabled[item.name] + if (status === undefined) { + status = await item.enabled() + enabled[item.name] = status + } + return status + } + + async function getFormatter(ext: string) { + const matching = Object.values(formatters).filter((item) => item.extensions.includes(ext)) + const checks = await Promise.all( + matching.map(async (item) => { + log.info("checking", { name: item.name, ext }) + const on = await isEnabled(item) + if (on) { + log.info("enabled", { name: item.name, ext }) + } + return { + item, + enabled: on, + } + }), + ) + return checks.filter((x) => x.enabled).map((x) => x.item) + } + + yield* Effect.acquireRelease( + Effect.sync(() => + Bus.subscribe( + File.Event.Edited, + Instance.bind(async (payload) => { + const file = payload.properties.file + log.info("formatting", { file }) + const ext = path.extname(file) + + for (const item of await getFormatter(ext)) { + log.info("running", { command: item.command }) + try { + const proc = Process.spawn( + item.command.map((x) => x.replace("$FILE", file)), + { + cwd: Instance.directory, + env: { ...process.env, ...item.environment }, + stdout: "ignore", + stderr: "ignore", + }, + ) + const exit = await proc.exited + if (exit !== 0) { + log.error("failed", { + command: item.command, + ...item.environment, + }) + } + } catch (error) { + log.error("failed to format file", { + error, + command: item.command, + ...item.environment, + file, + }) + } + } + }), + ), + ), + (unsubscribe) => Effect.sync(unsubscribe), + ) + log.info("init") + + return { + formatters, + isEnabled, + } + }), + ) + + const init = Effect.fn("Format.init")(function* () { + yield* InstanceState.get(state) + }) + + const status = Effect.fn("Format.status")(function* () { + const { formatters, isEnabled } = yield* InstanceState.get(state) + const result: Status[] = [] + for (const formatter of Object.values(formatters)) { + const isOn = yield* Effect.promise(() => isEnabled(formatter)) + result.push({ + name: formatter.name, + extensions: formatter.extensions, + enabled: isOn, + }) + } + return result + }) + + return Service.of({ init, status }) + }), + ) + + const runPromise = makeRunPromise(Service, layer) + + export async function init() { + return runPromise((s) => s.init()) + } export async function status() { - return runPromiseInstance(S.Service.use((s) => s.status())) + return runPromise((s) => s.status()) } } diff --git a/packages/opencode/src/format/service.ts b/packages/opencode/src/format/service.ts deleted file mode 100644 index 64fff79497a..00000000000 --- a/packages/opencode/src/format/service.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { Effect, Layer, ServiceMap } from "effect" -import { InstanceContext } from "@/effect/instance-context" -import path from "path" -import { mergeDeep } from "remeda" -import z from "zod" -import { Bus } from "../bus" -import { Config } from "../config/config" -import { File } from "../file/service" -import { Instance } from "../project/instance" -import { Process } from "../util/process" -import { Log } from "../util/log" -import * as Formatter from "./formatter" - -export namespace Format { - const log = Log.create({ service: "format" }) - - export const Status = z - .object({ - name: z.string(), - extensions: z.string().array(), - enabled: z.boolean(), - }) - .meta({ - ref: "FormatterStatus", - }) - export type Status = z.infer - - export interface Interface { - readonly status: () => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@opencode/Format") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const instance = yield* InstanceContext - - const enabled: Record = {} - const formatters: Record = {} - - const cfg = yield* Effect.promise(() => Config.get()) - - if (cfg.formatter !== false) { - for (const item of Object.values(Formatter)) { - formatters[item.name] = item - } - for (const [name, item] of Object.entries(cfg.formatter ?? {})) { - if (item.disabled) { - delete formatters[name] - continue - } - const info = mergeDeep(formatters[name] ?? {}, { - command: [], - extensions: [], - ...item, - }) - - if (info.command.length === 0) continue - - formatters[name] = { - ...info, - name, - enabled: async () => true, - } - } - } else { - log.info("all formatters are disabled") - } - - async function isEnabled(item: Formatter.Info) { - let status = enabled[item.name] - if (status === undefined) { - status = await item.enabled() - enabled[item.name] = status - } - return status - } - - async function getFormatter(ext: string) { - const result = [] - for (const item of Object.values(formatters)) { - log.info("checking", { name: item.name, ext }) - if (!item.extensions.includes(ext)) continue - if (!(await isEnabled(item))) continue - log.info("enabled", { name: item.name, ext }) - result.push(item) - } - return result - } - - yield* Effect.acquireRelease( - Effect.sync(() => - Bus.subscribe( - File.Event.Edited, - Instance.bind(async (payload) => { - const file = payload.properties.file - log.info("formatting", { file }) - const ext = path.extname(file) - - for (const item of await getFormatter(ext)) { - log.info("running", { command: item.command }) - try { - const proc = Process.spawn( - item.command.map((x) => x.replace("$FILE", file)), - { - cwd: instance.directory, - env: { ...process.env, ...item.environment }, - stdout: "ignore", - stderr: "ignore", - }, - ) - const exit = await proc.exited - if (exit !== 0) { - log.error("failed", { - command: item.command, - ...item.environment, - }) - } - } catch (error) { - log.error("failed to format file", { - error, - command: item.command, - ...item.environment, - file, - }) - } - } - }), - ), - ), - (unsubscribe) => Effect.sync(unsubscribe), - ) - log.info("init") - - const status = Effect.fn("Format.status")(function* () { - const result: Status[] = [] - for (const formatter of Object.values(formatters)) { - const isOn = yield* Effect.promise(() => isEnabled(formatter)) - result.push({ - name: formatter.name, - extensions: formatter.extensions, - enabled: isOn, - }) - } - return result - }) - - return Service.of({ status }) - }), - ).pipe(Layer.fresh) -} diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index d0bd1032969..1e4e45f2cd4 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -1,6 +1,7 @@ import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" import { Effect, Layer, Schema, ServiceMap, Stream } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { makeRunPromise } from "@/effect/run-service" import { withTransientReadRetry } from "@/util/effect-http-client" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import path from "path" @@ -293,7 +294,7 @@ export namespace Installation { result = yield* run(["scoop", "install", `opencode@${target}`]) break default: - throw new Error(`Unknown method: ${m}`) + return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` }) } if (!result || result.code !== 0) { const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || "" @@ -329,27 +330,21 @@ export namespace Installation { Layer.provide(NodePath.layer), ) - // Legacy adapters — dynamic import avoids circular dependency since - // foundational modules (db.ts, provider/models.ts) import Installation - // at load time, and runtime transitively loads those same modules. - async function runPromise(f: (service: Interface) => Effect.Effect) { - const { runtime } = await import("@/effect/runtime") - return runtime.runPromise(Service.use(f)) - } + const runPromise = makeRunPromise(Service, defaultLayer) - export function info(): Promise { + export async function info(): Promise { return runPromise((svc) => svc.info()) } - export function method(): Promise { + export async function method(): Promise { return runPromise((svc) => svc.method()) } - export function latest(installMethod?: Method): Promise { + export async function latest(installMethod?: Method): Promise { return runPromise((svc) => svc.latest(installMethod)) } - export function upgrade(m: Method, target: string): Promise { + export async function upgrade(m: Method, target: string): Promise { return runPromise((svc) => svc.upgrade(m, target)) } } diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 01ac7689717..63e6570189d 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -1,52 +1,322 @@ -import { runPromiseInstance } from "@/effect/runtime" -import { fn } from "@/util/fn" +import { Bus } from "@/bus" +import { BusEvent } from "@/bus/bus-event" +import { Config } from "@/config/config" +import { InstanceState } from "@/effect/instance-state" +import { makeRunPromise } from "@/effect/run-service" +import { ProjectID } from "@/project/schema" +import { Instance } from "@/project/instance" +import { MessageID, SessionID } from "@/session/schema" +import { PermissionTable } from "@/session/session.sql" +import { Database, eq } from "@/storage/db" +import { Log } from "@/util/log" +import { Wildcard } from "@/util/wildcard" +import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" +import os from "os" import z from "zod" -import { Permission as S } from "./service" +import { evaluate as evalRule } from "./evaluate" +import { PermissionID } from "./schema" -export namespace PermissionNext { - export const Action = S.Action - export type Action = S.Action +export namespace Permission { + const log = Log.create({ service: "permission" }) - export const Rule = S.Rule - export type Rule = S.Rule + export const Action = z.enum(["allow", "deny", "ask"]).meta({ + ref: "PermissionAction", + }) + export type Action = z.infer - export const Ruleset = S.Ruleset - export type Ruleset = S.Ruleset + export const Rule = z + .object({ + permission: z.string(), + pattern: z.string(), + action: Action, + }) + .meta({ + ref: "PermissionRule", + }) + export type Rule = z.infer - export const Request = S.Request - export type Request = S.Request + export const Ruleset = Rule.array().meta({ + ref: "PermissionRuleset", + }) + export type Ruleset = z.infer - export const Reply = S.Reply - export type Reply = S.Reply + export const Request = z + .object({ + id: PermissionID.zod, + sessionID: SessionID.zod, + permission: z.string(), + patterns: z.string().array(), + metadata: z.record(z.string(), z.any()), + always: z.string().array(), + tool: z + .object({ + messageID: MessageID.zod, + callID: z.string(), + }) + .optional(), + }) + .meta({ + ref: "PermissionRequest", + }) + export type Request = z.infer - export const Approval = S.Approval - export type Approval = z.infer + export const Reply = z.enum(["once", "always", "reject"]) + export type Reply = z.infer - export const Event = S.Event + export const Approval = z.object({ + projectID: ProjectID.zod, + patterns: z.string().array(), + }) - export const RejectedError = S.RejectedError - export const CorrectedError = S.CorrectedError - export const DeniedError = S.DeniedError - export type Error = S.Error + export const Event = { + Asked: BusEvent.define("permission.asked", Request), + Replied: BusEvent.define( + "permission.replied", + z.object({ + sessionID: SessionID.zod, + requestID: PermissionID.zod, + reply: Reply, + }), + ), + } + + export class RejectedError extends Schema.TaggedErrorClass()("PermissionRejectedError", {}) { + override get message() { + return "The user rejected permission to use this specific tool call." + } + } + + export class CorrectedError extends Schema.TaggedErrorClass()("PermissionCorrectedError", { + feedback: Schema.String, + }) { + override get message() { + return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}` + } + } + + export class DeniedError extends Schema.TaggedErrorClass()("PermissionDeniedError", { + ruleset: Schema.Any, + }) { + override get message() { + return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}` + } + } + + export type Error = DeniedError | RejectedError | CorrectedError + + export const AskInput = Request.partial({ id: true }).extend({ + ruleset: Ruleset, + }) + + export const ReplyInput = z.object({ + requestID: PermissionID.zod, + reply: Reply, + message: z.string().optional(), + }) + + export interface Interface { + readonly ask: (input: z.infer) => Effect.Effect + readonly reply: (input: z.infer) => Effect.Effect + readonly list: () => Effect.Effect + } + + interface PendingEntry { + info: Request + deferred: Deferred.Deferred + } + + interface State { + pending: Map + approved: Ruleset + } + + export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { + log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() }) + return evalRule(permission, pattern, ...rulesets) + } + + export class Service extends ServiceMap.Service()("@opencode/Permission") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const state = yield* InstanceState.make( + Effect.fn("Permission.state")(function* (ctx) { + const row = Database.use((db) => + db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(), + ) + const state = { + pending: new Map(), + approved: row?.data ?? [], + } + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + for (const item of state.pending.values()) { + yield* Deferred.fail(item.deferred, new RejectedError()) + } + state.pending.clear() + }), + ) + + return state + }), + ) - export const AskInput = S.AskInput - export const ReplyInput = S.ReplyInput + const ask = Effect.fn("Permission.ask")(function* (input: z.infer) { + const { approved, pending } = yield* InstanceState.get(state) + const { ruleset, ...request } = input + let needsAsk = false - export type Interface = S.Interface + for (const pattern of request.patterns) { + const rule = evaluate(request.permission, pattern, ruleset, approved) + log.info("evaluated", { permission: request.permission, pattern, action: rule }) + if (rule.action === "deny") { + return yield* new DeniedError({ + ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)), + }) + } + if (rule.action === "allow") continue + needsAsk = true + } - export const Service = S.Service - export const layer = S.layer + if (!needsAsk) return - export const evaluate = S.evaluate - export const fromConfig = S.fromConfig - export const merge = S.merge - export const disabled = S.disabled + const id = request.id ?? PermissionID.ascending() + const info: Request = { + id, + ...request, + } + log.info("asking", { id, permission: info.permission, patterns: info.patterns }) - export const ask = fn(S.AskInput, async (input) => runPromiseInstance(S.Service.use((s) => s.ask(input)))) + const deferred = yield* Deferred.make() + pending.set(id, { info, deferred }) + void Bus.publish(Event.Asked, info) + return yield* Effect.ensuring( + Deferred.await(deferred), + Effect.sync(() => { + pending.delete(id) + }), + ) + }) - export const reply = fn(S.ReplyInput, async (input) => runPromiseInstance(S.Service.use((s) => s.reply(input)))) + const reply = Effect.fn("Permission.reply")(function* (input: z.infer) { + const { approved, pending } = yield* InstanceState.get(state) + const existing = pending.get(input.requestID) + if (!existing) return + + pending.delete(input.requestID) + void Bus.publish(Event.Replied, { + sessionID: existing.info.sessionID, + requestID: existing.info.id, + reply: input.reply, + }) + + if (input.reply === "reject") { + yield* Deferred.fail( + existing.deferred, + input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(), + ) + + for (const [id, item] of pending.entries()) { + if (item.info.sessionID !== existing.info.sessionID) continue + pending.delete(id) + void Bus.publish(Event.Replied, { + sessionID: item.info.sessionID, + requestID: item.info.id, + reply: "reject", + }) + yield* Deferred.fail(item.deferred, new RejectedError()) + } + return + } + + yield* Deferred.succeed(existing.deferred, undefined) + if (input.reply === "once") return + + for (const pattern of existing.info.always) { + approved.push({ + permission: existing.info.permission, + pattern, + action: "allow", + }) + } + + for (const [id, item] of pending.entries()) { + if (item.info.sessionID !== existing.info.sessionID) continue + const ok = item.info.patterns.every( + (pattern) => evaluate(item.info.permission, pattern, approved).action === "allow", + ) + if (!ok) continue + pending.delete(id) + void Bus.publish(Event.Replied, { + sessionID: item.info.sessionID, + requestID: item.info.id, + reply: "always", + }) + yield* Deferred.succeed(item.deferred, undefined) + } + }) + + const list = Effect.fn("Permission.list")(function* () { + const pending = (yield* InstanceState.get(state)).pending + return Array.from(pending.values(), (item) => item.info) + }) + + return Service.of({ ask, reply, list }) + }), + ) + + function expand(pattern: string): string { + if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1) + if (pattern === "~") return os.homedir() + if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5) + if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5) + return pattern + } + + export function fromConfig(permission: Config.Permission) { + const ruleset: Ruleset = [] + for (const [key, value] of Object.entries(permission)) { + if (typeof value === "string") { + ruleset.push({ permission: key, action: value, pattern: "*" }) + continue + } + ruleset.push( + ...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })), + ) + } + return ruleset + } + + export function merge(...rulesets: Ruleset[]): Ruleset { + return rulesets.flat() + } + + const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"] + + export function disabled(tools: string[], ruleset: Ruleset): Set { + const result = new Set() + for (const tool of tools) { + const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool + const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission)) + if (!rule) continue + if (rule.pattern === "*" && rule.action === "deny") result.add(tool) + } + return result + } + + export const runPromise = makeRunPromise(Service, layer) + + export async function ask(input: z.infer) { + return runPromise((s) => s.ask(input)) + } + + export async function reply(input: z.infer) { + return runPromise((s) => s.reply(input)) + } export async function list() { - return runPromiseInstance(S.Service.use((s) => s.list())) + return runPromise((s) => s.list()) } } diff --git a/packages/opencode/src/permission/service.ts b/packages/opencode/src/permission/service.ts deleted file mode 100644 index 08475520b2e..00000000000 --- a/packages/opencode/src/permission/service.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { Bus } from "@/bus" -import { BusEvent } from "@/bus/bus-event" -import { Config } from "@/config/config" -import { InstanceContext } from "@/effect/instance-context" -import { ProjectID } from "@/project/schema" -import { MessageID, SessionID } from "@/session/schema" -import { PermissionTable } from "@/session/session.sql" -import { Database, eq } from "@/storage/db" -import { Log } from "@/util/log" -import { Wildcard } from "@/util/wildcard" -import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" -import os from "os" -import z from "zod" -import { evaluate as evalRule } from "./evaluate" -import { PermissionID } from "./schema" - -export namespace Permission { - const log = Log.create({ service: "permission" }) - - export const Action = z.enum(["allow", "deny", "ask"]).meta({ - ref: "PermissionAction", - }) - export type Action = z.infer - - export const Rule = z - .object({ - permission: z.string(), - pattern: z.string(), - action: Action, - }) - .meta({ - ref: "PermissionRule", - }) - export type Rule = z.infer - - export const Ruleset = Rule.array().meta({ - ref: "PermissionRuleset", - }) - export type Ruleset = z.infer - - export const Request = z - .object({ - id: PermissionID.zod, - sessionID: SessionID.zod, - permission: z.string(), - patterns: z.string().array(), - metadata: z.record(z.string(), z.any()), - always: z.string().array(), - tool: z - .object({ - messageID: MessageID.zod, - callID: z.string(), - }) - .optional(), - }) - .meta({ - ref: "PermissionRequest", - }) - export type Request = z.infer - - export const Reply = z.enum(["once", "always", "reject"]) - export type Reply = z.infer - - export const Approval = z.object({ - projectID: ProjectID.zod, - patterns: z.string().array(), - }) - - export const Event = { - Asked: BusEvent.define("permission.asked", Request), - Replied: BusEvent.define( - "permission.replied", - z.object({ - sessionID: SessionID.zod, - requestID: PermissionID.zod, - reply: Reply, - }), - ), - } - - export class RejectedError extends Schema.TaggedErrorClass()("PermissionRejectedError", {}) { - override get message() { - return "The user rejected permission to use this specific tool call." - } - } - - export class CorrectedError extends Schema.TaggedErrorClass()("PermissionCorrectedError", { - feedback: Schema.String, - }) { - override get message() { - return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}` - } - } - - export class DeniedError extends Schema.TaggedErrorClass()("PermissionDeniedError", { - ruleset: Schema.Any, - }) { - override get message() { - return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}` - } - } - - export type Error = DeniedError | RejectedError | CorrectedError - - export const AskInput = Request.partial({ id: true }).extend({ - ruleset: Ruleset, - }) - - export const ReplyInput = z.object({ - requestID: PermissionID.zod, - reply: Reply, - message: z.string().optional(), - }) - - export interface Interface { - readonly ask: (input: z.infer) => Effect.Effect - readonly reply: (input: z.infer) => Effect.Effect - readonly list: () => Effect.Effect - } - - interface PendingEntry { - info: Request - deferred: Deferred.Deferred - } - - export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { - log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() }) - return evalRule(permission, pattern, ...rulesets) - } - - export class Service extends ServiceMap.Service()("@opencode/PermissionNext") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const { project } = yield* InstanceContext - const row = Database.use((db) => - db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(), - ) - const pending = new Map() - const approved: Ruleset = row?.data ?? [] - - const ask = Effect.fn("Permission.ask")(function* (input: z.infer) { - const { ruleset, ...request } = input - let needsAsk = false - - for (const pattern of request.patterns) { - const rule = evaluate(request.permission, pattern, ruleset, approved) - log.info("evaluated", { permission: request.permission, pattern, action: rule }) - if (rule.action === "deny") { - return yield* new DeniedError({ - ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)), - }) - } - if (rule.action === "allow") continue - needsAsk = true - } - - if (!needsAsk) return - - const id = request.id ?? PermissionID.ascending() - const info: Request = { - id, - ...request, - } - log.info("asking", { id, permission: info.permission, patterns: info.patterns }) - - const deferred = yield* Deferred.make() - pending.set(id, { info, deferred }) - void Bus.publish(Event.Asked, info) - return yield* Effect.ensuring( - Deferred.await(deferred), - Effect.sync(() => { - pending.delete(id) - }), - ) - }) - - const reply = Effect.fn("Permission.reply")(function* (input: z.infer) { - const existing = pending.get(input.requestID) - if (!existing) return - - pending.delete(input.requestID) - void Bus.publish(Event.Replied, { - sessionID: existing.info.sessionID, - requestID: existing.info.id, - reply: input.reply, - }) - - if (input.reply === "reject") { - yield* Deferred.fail( - existing.deferred, - input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(), - ) - - for (const [id, item] of pending.entries()) { - if (item.info.sessionID !== existing.info.sessionID) continue - pending.delete(id) - void Bus.publish(Event.Replied, { - sessionID: item.info.sessionID, - requestID: item.info.id, - reply: "reject", - }) - yield* Deferred.fail(item.deferred, new RejectedError()) - } - return - } - - yield* Deferred.succeed(existing.deferred, undefined) - if (input.reply === "once") return - - for (const pattern of existing.info.always) { - approved.push({ - permission: existing.info.permission, - pattern, - action: "allow", - }) - } - - for (const [id, item] of pending.entries()) { - if (item.info.sessionID !== existing.info.sessionID) continue - const ok = item.info.patterns.every( - (pattern) => evaluate(item.info.permission, pattern, approved).action === "allow", - ) - if (!ok) continue - pending.delete(id) - void Bus.publish(Event.Replied, { - sessionID: item.info.sessionID, - requestID: item.info.id, - reply: "always", - }) - yield* Deferred.succeed(item.deferred, undefined) - } - }) - - const list = Effect.fn("Permission.list")(function* () { - return Array.from(pending.values(), (item) => item.info) - }) - - return Service.of({ ask, reply, list }) - }), - ).pipe(Layer.fresh) - - function expand(pattern: string): string { - if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1) - if (pattern === "~") return os.homedir() - if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5) - if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5) - return pattern - } - - export function fromConfig(permission: Config.Permission) { - const ruleset: Ruleset = [] - for (const [key, value] of Object.entries(permission)) { - if (typeof value === "string") { - ruleset.push({ permission: key, action: value, pattern: "*" }) - continue - } - ruleset.push( - ...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })), - ) - } - return ruleset - } - - export function merge(...rulesets: Ruleset[]): Ruleset { - return rulesets.flat() - } - - const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"] - - export function disabled(tools: string[], ruleset: Ruleset): Set { - const result = new Set() - for (const tool of tools) { - const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool - const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission)) - if (!rule) continue - if (rule.pattern === "*" && rule.action === "deny") result.add(tool) - } - return result - } -} diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 86403f3da94..a8ad84297a4 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -1,7 +1,11 @@ import { Plugin } from "../plugin" +import { Format } from "../format" import { LSP } from "../lsp" import { File } from "../file" +import { FileWatcher } from "../file/watcher" +import { Snapshot } from "../snapshot" import { Project } from "./project" +import { Vcs } from "./vcs" import { Bus } from "../bus" import { Command } from "../command" import { Instance } from "./instance" @@ -12,8 +16,12 @@ export async function InstanceBootstrap() { Log.Default.info("bootstrapping", { directory: Instance.directory }) await Plugin.init() ShareNext.init() + Format.init() await LSP.init() File.init() + FileWatcher.init() + Vcs.init() + Snapshot.init() Bus.subscribe(Command.Event.Executed, async (payload) => { if (payload.properties.name === Command.Default.INIT) { diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 6075540161b..4c9b2e107bc 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -7,13 +7,13 @@ import { Context } from "../util/context" import { Project } from "./project" import { State } from "./state" -interface Context { +export interface Shape { directory: string worktree: string project: Project.Info } -const context = Context.create("instance") -const cache = new Map>() +const context = Context.create("instance") +const cache = new Map>() const disposal = { all: undefined as Promise | undefined, @@ -52,7 +52,7 @@ function boot(input: { directory: string; init?: () => Promise; project?: P }) } -function track(directory: string, next: Promise) { +function track(directory: string, next: Promise) { const task = next.catch((error) => { if (cache.get(directory) === task) cache.delete(directory) throw error diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 9a9e42ecf88..dea25b91b43 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -1,7 +1,8 @@ import { Effect, Layer, ServiceMap } from "effect" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" -import { InstanceContext } from "@/effect/instance-context" +import { InstanceState } from "@/effect/instance-state" +import { makeRunPromise } from "@/effect/run-service" import { FileWatcher } from "@/file/watcher" import { Log } from "@/util/log" import { git } from "@/util/git" @@ -30,54 +31,81 @@ export namespace Vcs { export type Info = z.infer export interface Interface { + readonly init: () => Effect.Effect readonly branch: () => Effect.Effect } + interface State { + current: string | undefined + } + export class Service extends ServiceMap.Service()("@opencode/Vcs") {} export const layer = Layer.effect( Service, Effect.gen(function* () { - const instance = yield* InstanceContext - let currentBranch: string | undefined + const state = yield* InstanceState.make( + Effect.fn("Vcs.state")((ctx) => + Effect.gen(function* () { + if (ctx.project.vcs !== "git") { + return { current: undefined } + } + + const getCurrentBranch = async () => { + const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], { + cwd: ctx.worktree, + }) + if (result.exitCode !== 0) return undefined + const text = result.text().trim() + return text || undefined + } - if (instance.project.vcs === "git") { - const getCurrentBranch = async () => { - const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], { - cwd: instance.project.worktree, - }) - if (result.exitCode !== 0) return undefined - const text = result.text().trim() - return text || undefined - } + const value = { + current: yield* Effect.promise(() => getCurrentBranch()), + } + log.info("initialized", { branch: value.current }) - currentBranch = yield* Effect.promise(() => getCurrentBranch()) - log.info("initialized", { branch: currentBranch }) + yield* Effect.acquireRelease( + Effect.sync(() => + Bus.subscribe( + FileWatcher.Event.Updated, + Instance.bind(async (evt) => { + if (!evt.properties.file.endsWith("HEAD")) return + const next = await getCurrentBranch() + if (next !== value.current) { + log.info("branch changed", { from: value.current, to: next }) + value.current = next + Bus.publish(Event.BranchUpdated, { branch: next }) + } + }), + ), + ), + (unsubscribe) => Effect.sync(unsubscribe), + ) - yield* Effect.acquireRelease( - Effect.sync(() => - Bus.subscribe( - FileWatcher.Event.Updated, - Instance.bind(async (evt) => { - if (!evt.properties.file.endsWith("HEAD")) return - const next = await getCurrentBranch() - if (next !== currentBranch) { - log.info("branch changed", { from: currentBranch, to: next }) - currentBranch = next - Bus.publish(Event.BranchUpdated, { branch: next }) - } - }), - ), - ), - (unsubscribe) => Effect.sync(unsubscribe), - ) - } + return value + }), + ), + ) return Service.of({ + init: Effect.fn("Vcs.init")(function* () { + yield* InstanceState.get(state) + }), branch: Effect.fn("Vcs.branch")(function* () { - return currentBranch + return yield* InstanceState.use(state, (x) => x.current) }), }) }), - ).pipe(Layer.fresh) + ) + + const runPromise = makeRunPromise(Service, layer) + + export function init() { + return runPromise((svc) => svc.init()) + } + + export function branch() { + return runPromise((svc) => svc.branch()) + } } diff --git a/packages/opencode/src/provider/auth-service.ts b/packages/opencode/src/provider/auth-service.ts deleted file mode 100644 index 5045e1edd22..00000000000 --- a/packages/opencode/src/provider/auth-service.ts +++ /dev/null @@ -1,215 +0,0 @@ -import type { AuthOuathResult } from "@opencode-ai/plugin" -import { NamedError } from "@opencode-ai/util/error" -import * as Auth from "@/auth/effect" -import { ProviderID } from "./schema" -import { Array as Arr, Effect, Layer, Record, Result, ServiceMap, Struct } from "effect" -import z from "zod" - -export namespace ProviderAuth { - export const Method = z - .object({ - type: z.union([z.literal("oauth"), z.literal("api")]), - label: z.string(), - prompts: z - .array( - z.union([ - z.object({ - type: z.literal("text"), - key: z.string(), - message: z.string(), - placeholder: z.string().optional(), - when: z - .object({ - key: z.string(), - op: z.union([z.literal("eq"), z.literal("neq")]), - value: z.string(), - }) - .optional(), - }), - z.object({ - type: z.literal("select"), - key: z.string(), - message: z.string(), - options: z.array( - z.object({ - label: z.string(), - value: z.string(), - hint: z.string().optional(), - }), - ), - when: z - .object({ - key: z.string(), - op: z.union([z.literal("eq"), z.literal("neq")]), - value: z.string(), - }) - .optional(), - }), - ]), - ) - .optional(), - }) - .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", 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 const ValidationFailed = NamedError.create( - "ProviderAuthValidationFailed", - z.object({ - field: z.string(), - message: z.string(), - }), - ) - - export type Error = - | Auth.AuthError - | InstanceType - | InstanceType - | InstanceType - | InstanceType - - export interface Interface { - readonly methods: () => Effect.Effect> - readonly authorize: (input: { - providerID: ProviderID - method: number - inputs?: Record - }) => Effect.Effect - readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@opencode/ProviderAuth") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const auth = yield* Auth.Auth.Service - const hooks = yield* Effect.promise(async () => { - const mod = await import("../plugin") - const plugins = await mod.Plugin.list() - return Record.fromEntries( - Arr.filterMap(plugins, (x) => - x.auth?.provider !== undefined - ? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const) - : Result.failVoid, - ), - ) - }) - const pending = new Map() - - const methods = Effect.fn("ProviderAuth.methods")(function* () { - return Record.map(hooks, (item) => - item.methods.map( - (method): Method => ({ - type: method.type, - label: method.label, - prompts: method.prompts?.map((prompt) => { - if (prompt.type === "select") { - return { - type: "select" as const, - key: prompt.key, - message: prompt.message, - options: prompt.options, - when: prompt.when, - } - } - return { - type: "text" as const, - key: prompt.key, - message: prompt.message, - placeholder: prompt.placeholder, - when: prompt.when, - } - }), - }), - ), - ) - }) - - const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: { - providerID: ProviderID - method: number - inputs?: Record - }) { - const method = hooks[input.providerID].methods[input.method] - if (method.type !== "oauth") return - - if (method.prompts && input.inputs) { - for (const prompt of method.prompts) { - if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) { - const error = prompt.validate(input.inputs[prompt.key]) - if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error })) - } - } - } - - const result = yield* Effect.promise(() => method.authorize(input.inputs)) - pending.set(input.providerID, result) - return { - url: result.url, - method: result.method, - instructions: result.instructions, - } - }) - - const callback = Effect.fn("ProviderAuth.callback")(function* (input: { - providerID: ProviderID - method: number - code?: string - }) { - const match = pending.get(input.providerID) - if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID })) - 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({})) - - 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 } : {}), - }) - } - }) - - return Service.of({ methods, authorize, callback }) - }), - ).pipe(Layer.fresh) - - export const defaultLayer = layer.pipe(Layer.provide(Auth.Auth.layer)) -} diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 8ede977a59f..2180d30632c 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -1,48 +1,250 @@ -import { runPromiseInstance } from "@/effect/runtime" -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 { InstanceState } from "@/effect/instance-state" +import { makeRunPromise } from "@/effect/run-service" +import { Plugin } from "../plugin" import { ProviderID } from "./schema" +import { Array as Arr, Effect, Layer, Record, Result, ServiceMap } from "effect" import z from "zod" -import { ProviderAuth as S } from "./auth-service" export namespace ProviderAuth { - export const Method = S.Method - export type Method = S.Method + export const Method = z + .object({ + type: z.union([z.literal("oauth"), z.literal("api")]), + label: z.string(), + prompts: z + .array( + z.union([ + z.object({ + type: z.literal("text"), + key: z.string(), + message: z.string(), + placeholder: z.string().optional(), + when: z + .object({ + key: z.string(), + op: z.union([z.literal("eq"), z.literal("neq")]), + value: z.string(), + }) + .optional(), + }), + z.object({ + type: z.literal("select"), + key: z.string(), + message: z.string(), + options: z.array( + z.object({ + label: z.string(), + value: z.string(), + hint: z.string().optional(), + }), + ), + when: z + .object({ + key: z.string(), + op: z.union([z.literal("eq"), z.literal("neq")]), + value: z.string(), + }) + .optional(), + }), + ]), + ) + .optional(), + }) + .meta({ + ref: "ProviderAuthMethod", + }) + export type Method = z.infer - export const Authorization = S.Authorization - export type Authorization = S.Authorization + 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 = S.OauthMissing - export const OauthCodeMissing = S.OauthCodeMissing - export const OauthCallbackFailed = S.OauthCallbackFailed - export const ValidationFailed = S.ValidationFailed - export type Error = S.Error + export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod })) - export type Interface = S.Interface - - export const Service = S.Service - export const layer = S.layer - export const defaultLayer = S.defaultLayer + export const OauthCodeMissing = NamedError.create( + "ProviderAuthOauthCodeMissing", + z.object({ providerID: ProviderID.zod }), + ) - export async function methods() { - return runPromiseInstance(S.Service.use((svc) => svc.methods())) - } + export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({})) - export const authorize = fn( + export const ValidationFailed = NamedError.create( + "ProviderAuthValidationFailed", z.object({ - providerID: ProviderID.zod, - method: z.number(), - inputs: z.record(z.string(), z.string()).optional(), + field: z.string(), + message: z.string(), }), - async (input): Promise => - runPromiseInstance(S.Service.use((svc) => svc.authorize(input))), ) - export const callback = fn( - z.object({ - providerID: ProviderID.zod, - method: z.number(), - code: z.string().optional(), + export type Error = + | Auth.AuthError + | InstanceType + | InstanceType + | InstanceType + | InstanceType + + type Hook = NonNullable + + export interface Interface { + readonly methods: () => Effect.Effect> + readonly authorize: (input: { + providerID: ProviderID + method: number + inputs?: Record + }) => Effect.Effect + readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect + } + + interface State { + hooks: Record + pending: Map + } + + export class Service extends ServiceMap.Service()("@opencode/ProviderAuth") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const auth = yield* Auth.Service + const state = yield* InstanceState.make( + Effect.fn("ProviderAuth.state")(() => + Effect.promise(async () => { + const plugins = await Plugin.list() + return { + hooks: Record.fromEntries( + Arr.filterMap(plugins, (x) => + x.auth?.provider !== undefined + ? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const) + : Result.failVoid, + ), + ), + pending: new Map(), + } + })), + ) + + const methods = Effect.fn("ProviderAuth.methods")(function* () { + const hooks = (yield* InstanceState.get(state)).hooks + return Record.map(hooks, (item) => + item.methods.map( + (method): Method => ({ + type: method.type, + label: method.label, + prompts: method.prompts?.map((prompt) => { + if (prompt.type === "select") { + return { + type: "select" as const, + key: prompt.key, + message: prompt.message, + options: prompt.options, + when: prompt.when, + } + } + return { + type: "text" as const, + key: prompt.key, + message: prompt.message, + placeholder: prompt.placeholder, + when: prompt.when, + } + }), + }), + ), + ) + }) + + const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: { + providerID: ProviderID + method: number + inputs?: Record + }) { + const { hooks, pending } = yield* InstanceState.get(state) + const method = hooks[input.providerID].methods[input.method] + if (method.type !== "oauth") return + + if (method.prompts && input.inputs) { + for (const prompt of method.prompts) { + if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) { + const error = prompt.validate(input.inputs[prompt.key]) + if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error })) + } + } + } + + const result = yield* Effect.promise(() => method.authorize(input.inputs)) + pending.set(input.providerID, result) + return { + url: result.url, + method: result.method, + instructions: result.instructions, + } + }) + + const callback = Effect.fn("ProviderAuth.callback")(function* (input: { + providerID: ProviderID + method: number + code?: string + }) { + const pending = (yield* InstanceState.get(state)).pending + const match = pending.get(input.providerID) + if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID })) + 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({})) + + 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 } : {}), + }) + } + }) + + return Service.of({ methods, authorize, callback }) }), - async (input) => runPromiseInstance(S.Service.use((svc) => svc.callback(input))), ) + + export const defaultLayer = layer.pipe(Layer.provide(Auth.layer)) + + const runPromise = makeRunPromise(Service, defaultLayer) + + export async function methods() { + return runPromise((svc) => svc.methods()) + } + + export async function authorize(input: { + providerID: ProviderID + method: number + inputs?: Record + }): Promise { + return runPromise((svc) => svc.authorize(input)) + } + + export async function callback(input: { providerID: ProviderID; method: number; code?: string }) { + return runPromise((svc) => svc.callback(input)) + } } diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index de009519083..a0d62d94b83 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -1,49 +1,221 @@ -import { runPromiseInstance } from "@/effect/runtime" -import type { MessageID, SessionID } from "@/session/schema" -import type { QuestionID } from "./schema" -import { Question as S } from "./service" +import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" +import { Bus } from "@/bus" +import { BusEvent } from "@/bus/bus-event" +import { InstanceState } from "@/effect/instance-state" +import { makeRunPromise } from "@/effect/run-service" +import { SessionID, MessageID } from "@/session/schema" +import { Log } from "@/util/log" +import z from "zod" +import { QuestionID } from "./schema" export namespace Question { - export const Option = S.Option - export type Option = S.Option + const log = Log.create({ service: "question" }) - export const Info = S.Info - export type Info = S.Info + // Schemas - export const Request = S.Request - export type Request = S.Request + 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 Answer = S.Answer - export type Answer = S.Answer + 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 Reply = S.Reply - export type Reply = S.Reply + 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 Event = S.Event - export const RejectedError = S.RejectedError + export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" }) + export type Answer = z.infer - export type Interface = S.Interface + 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 Service = S.Service - export const layer = S.layer + 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" + } + } + + interface PendingEntry { + info: Request + deferred: Deferred.Deferred + } + + interface State { + pending: Map + } + + // Service + + 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 state = yield* InstanceState.make( + Effect.fn("Question.state")(function* () { + const state = { + pending: new Map(), + } + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + for (const item of state.pending.values()) { + yield* Deferred.fail(item.deferred, new RejectedError()) + } + state.pending.clear() + }), + ) + + return state + }), + ) + + const ask = Effect.fn("Question.ask")(function* (input: { + sessionID: SessionID + questions: Info[] + tool?: { messageID: MessageID; callID: string } + }) { + const pending = (yield* InstanceState.get(state)).pending + 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 pending = (yield* InstanceState.get(state)).pending + 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 pending = (yield* InstanceState.get(state)).pending + 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* () { + const pending = (yield* InstanceState.get(state)).pending + return Array.from(pending.values(), (x) => x.info) + }) + + return Service.of({ ask, reply, reject, list }) + }), + ) + + const runPromise = makeRunPromise(Service, layer) export async function ask(input: { sessionID: SessionID questions: Info[] tool?: { messageID: MessageID; callID: string } }): Promise { - return runPromiseInstance(S.Service.use((s) => s.ask(input))) + return runPromise((s) => s.ask(input)) } export async function reply(input: { requestID: QuestionID; answers: Answer[] }) { - return runPromiseInstance(S.Service.use((s) => s.reply(input))) + return runPromise((s) => s.reply(input)) } export async function reject(requestID: QuestionID) { - return runPromiseInstance(S.Service.use((s) => s.reject(requestID))) + return runPromise((s) => s.reject(requestID)) } export async function list() { - return runPromiseInstance(S.Service.use((s) => s.list())) + return runPromise((s) => s.list()) } } diff --git a/packages/opencode/src/question/service.ts b/packages/opencode/src/question/service.ts deleted file mode 100644 index a23703e97ac..00000000000 --- 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" }) - -export namespace Question { - // Schemas - - 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" - } - } - - interface PendingEntry { - info: Request - deferred: Deferred.Deferred - } - - // Service - - 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 }) - }), - ).pipe(Layer.fresh) -} diff --git a/packages/opencode/src/server/routes/permission.ts b/packages/opencode/src/server/routes/permission.ts index cc6c26d4358..aae9a9c3a69 100644 --- a/packages/opencode/src/server/routes/permission.ts +++ b/packages/opencode/src/server/routes/permission.ts @@ -1,7 +1,7 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" -import { PermissionNext } from "@/permission" +import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" import { errors } from "../error" import { lazy } from "../../util/lazy" @@ -32,11 +32,11 @@ export const PermissionRoutes = lazy(() => requestID: PermissionID.zod, }), ), - validator("json", z.object({ reply: PermissionNext.Reply, message: z.string().optional() })), + validator("json", z.object({ reply: Permission.Reply, message: z.string().optional() })), async (c) => { const params = c.req.valid("param") const json = c.req.valid("json") - await PermissionNext.reply({ + await Permission.reply({ requestID: params.requestID, reply: json.reply, message: json.message, @@ -55,14 +55,14 @@ export const PermissionRoutes = lazy(() => description: "List of pending permissions", content: { "application/json": { - schema: resolver(PermissionNext.Request.array()), + schema: resolver(Permission.Request.array()), }, }, }, }, }), async (c) => { - const permissions = await PermissionNext.list() + const permissions = await Permission.list() return c.json(permissions) }, ), diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 564bb496b50..e399636ad84 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -12,9 +12,9 @@ import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" import { Todo } from "../../session/todo" import { Agent } from "../../agent/agent" -import { Snapshot } from "@/snapshot/service" +import { Snapshot } from "@/snapshot" import { Log } from "../../util/log" -import { PermissionNext } from "@/permission" +import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" import { ModelID, ProviderID } from "@/provider/schema" import { errors } from "../error" @@ -1010,10 +1010,10 @@ export const SessionRoutes = lazy(() => permissionID: PermissionID.zod, }), ), - validator("json", z.object({ response: PermissionNext.Reply })), + validator("json", z.object({ response: Permission.Reply })), async (c) => { const params = c.req.valid("param") - PermissionNext.reply({ + Permission.reply({ requestID: params.permissionID, reply: c.req.valid("json").response, }) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index a68becb1fba..7ead4df8a3c 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -12,9 +12,8 @@ import { Format } from "../format" import { TuiRoutes } from "./routes/tui" import { Instance } from "../project/instance" import { Vcs } from "../project/vcs" -import { runPromiseInstance } from "@/effect/runtime" import { Agent } from "../agent/agent" -import { Skill } from "../skill/skill" +import { Skill } from "../skill" import { Auth } from "../auth" import { Flag } from "../flag/flag" import { Command } from "../command" @@ -152,7 +151,7 @@ export namespace Server { providerID: ProviderID.zod, }), ), - validator("json", Auth.Info), + validator("json", Auth.Info.zod), async (c) => { const providerID = c.req.valid("param").providerID const info = c.req.valid("json") @@ -331,7 +330,7 @@ export namespace Server { }, }), async (c) => { - const branch = await runPromiseInstance(Vcs.Service.use((s) => s.branch())) + const branch = await Vcs.branch() return c.json({ branch, }) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index bbb7c97fd25..f2d436ff10d 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -20,7 +20,7 @@ import { Instance } from "../project/instance" import { SessionPrompt } from "./prompt" import { fn } from "@/util/fn" import { Command } from "../command" -import { Snapshot } from "@/snapshot/service" +import { Snapshot } from "@/snapshot" import { WorkspaceContext } from "../control-plane/workspace-context" import { ProjectID } from "../project/schema" import { WorkspaceID } from "../control-plane/schema" @@ -28,7 +28,7 @@ import { SessionID, MessageID, PartID } from "./schema" import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" -import { Permission as PermissionNext } from "@/permission/service" +import { Permission } from "@/permission" import { Global } from "@/global" import type { LanguageModelV2Usage } from "@ai-sdk/provider" import { iife } from "@/util/iife" @@ -148,7 +148,7 @@ export namespace Session { compacting: z.number().optional(), archived: z.number().optional(), }), - permission: PermissionNext.Ruleset.optional(), + permission: Permission.Ruleset.optional(), revert: z .object({ messageID: MessageID.zod, @@ -300,7 +300,7 @@ export namespace Session { parentID?: SessionID workspaceID?: WorkspaceID directory: string - permission?: PermissionNext.Ruleset + permission?: Permission.Ruleset }) { const result: Info = { id: SessionID.descending(input.id), @@ -423,7 +423,7 @@ export namespace Session { export const setPermission = fn( z.object({ sessionID: SessionID.zod, - permission: PermissionNext.Ruleset, + permission: Permission.Ruleset, }), async (input) => { return Database.use((db) => { diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index b28a595e1b8..a8009c49d49 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -21,7 +21,7 @@ import type { MessageV2 } from "./message-v2" import { Plugin } from "@/plugin" import { SystemPrompt } from "./system" import { Flag } from "@/flag/flag" -import { Permission as PermissionNext } from "@/permission/service" +import { Permission } from "@/permission" import { Auth } from "@/auth" export namespace LLM { @@ -33,7 +33,7 @@ export namespace LLM { sessionID: string model: Provider.Model agent: Agent.Info - permission?: PermissionNext.Ruleset + permission?: Permission.Ruleset system: string[] abort: AbortSignal messages: ModelMessage[] @@ -286,9 +286,9 @@ export namespace LLM { } async function resolveTools(input: Pick) { - const disabled = PermissionNext.disabled( + const disabled = Permission.disabled( Object.keys(input.tools), - PermissionNext.merge(input.agent.permission, input.permission ?? []), + Permission.merge(input.agent.permission, input.permission ?? []), ) for (const tool of Object.keys(input.tools)) { if (input.user.tools?.[tool] === false || disabled.has(tool)) { diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 3e1816e68e0..f1335f6f21a 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -4,7 +4,7 @@ import z from "zod" import { NamedError } from "@opencode-ai/util/error" import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" import { LSP } from "../lsp" -import { Snapshot } from "@/snapshot/service" +import { Snapshot } from "@/snapshot" import { fn } from "@/util/fn" import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage/db" import { MessageTable, PartTable, SessionTable } from "./session.sql" diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 2fe3310ca6d..c3a572f5b3f 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -12,8 +12,8 @@ import type { Provider } from "@/provider/provider" import { LLM } from "./llm" import { Config } from "@/config/config" import { SessionCompaction } from "./compaction" -import { PermissionNext } from "@/permission" -import { Question } from "@/question/service" +import { Permission } from "@/permission" +import { Question } from "@/question" import { PartID } from "./schema" import type { SessionID, MessageID } from "./schema" @@ -163,7 +163,7 @@ export namespace SessionProcessor { ) ) { const agent = await Agent.get(input.assistantMessage.agent) - await PermissionNext.ask({ + await Permission.ask({ permission: "doom_loop", patterns: [value.toolName], sessionID: input.assistantMessage.sessionID, @@ -219,7 +219,7 @@ export namespace SessionProcessor { }) if ( - value.error instanceof PermissionNext.RejectedError || + value.error instanceof Permission.RejectedError || value.error instanceof Question.RejectedError ) { blocked = shouldBreak diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index bac958ec103..5625c571cee 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -41,7 +41,7 @@ import { fn } from "@/util/fn" import { SessionProcessor } from "./processor" import { TaskTool } from "@/tool/task" import { Tool } from "@/tool/tool" -import { PermissionNext } from "@/permission" +import { Permission } from "@/permission" import { SessionStatus } from "./status" import { LLM } from "./llm" import { iife } from "@/util/iife" @@ -168,7 +168,7 @@ export namespace SessionPrompt { // this is backwards compatibility for allowing `tools` to be specified when // prompting - const permissions: PermissionNext.Ruleset = [] + const permissions: Permission.Ruleset = [] for (const [tool, enabled] of Object.entries(input.tools ?? {})) { permissions.push({ permission: tool, @@ -437,10 +437,10 @@ export namespace SessionPrompt { } satisfies MessageV2.ToolPart)) as MessageV2.ToolPart }, async ask(req) { - await PermissionNext.ask({ + await Permission.ask({ ...req, sessionID: sessionID, - ruleset: PermissionNext.merge(taskAgent.permission, session.permission ?? []), + ruleset: Permission.merge(taskAgent.permission, session.permission ?? []), }) }, } @@ -781,11 +781,11 @@ export namespace SessionPrompt { } }, async ask(req) { - await PermissionNext.ask({ + await Permission.ask({ ...req, sessionID: input.session.id, tool: { messageID: input.processor.message.id, callID: options.toolCallId }, - ruleset: PermissionNext.merge(input.agent.permission, input.session.permission ?? []), + ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []), }) }, }) @@ -1271,7 +1271,7 @@ export namespace SessionPrompt { if (part.type === "agent") { // Check if this agent would be denied by task permission - const perm = PermissionNext.evaluate("task", part.name, agent.permission) + const perm = Permission.evaluate("task", part.name, agent.permission) const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : "" return [ { diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index f73e16804a9..189a596873a 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -1,8 +1,8 @@ import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core" import { ProjectTable } from "../project/project.sql" import type { MessageV2 } from "./message-v2" -import type { Snapshot } from "../snapshot/service" -import type { Permission as PermissionNext } from "../permission/service" +import type { Snapshot } from "../snapshot" +import type { Permission } from "../permission" import type { ProjectID } from "../project/schema" import type { SessionID, MessageID, PartID } from "./schema" import type { WorkspaceID } from "../control-plane/schema" @@ -31,7 +31,7 @@ export const SessionTable = sqliteTable( summary_files: integer(), summary_diffs: text({ mode: "json" }).$type(), revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(), - permission: text({ mode: "json" }).$type(), + permission: text({ mode: "json" }).$type(), ...Timestamps, time_compacting: integer(), time_archived: integer(), @@ -99,5 +99,5 @@ export const PermissionTable = sqliteTable("permission", { .primaryKey() .references(() => ProjectTable.id, { onDelete: "cascade" }), ...Timestamps, - data: text({ mode: "json" }).notNull().$type(), + data: text({ mode: "json" }).notNull().$type(), }) diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index ead715cfb7f..ca324652d9d 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -11,7 +11,7 @@ import PROMPT_CODEX from "./prompt/codex.txt" import PROMPT_TRINITY from "./prompt/trinity.txt" import type { Provider } from "@/provider/provider" import type { Agent } from "@/agent/agent" -import { Permission as PermissionNext } from "@/permission/service" +import { Permission } from "@/permission" import { Skill } from "@/skill" export namespace SystemPrompt { @@ -53,7 +53,7 @@ export namespace SystemPrompt { } export async function skills(agent: Agent.Info) { - if (PermissionNext.disabled(["skill"], agent.permission).has("skill")) return + if (Permission.disabled(["skill"], agent.permission).has("skill")) return const list = await Skill.available(agent) diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index e911656c900..e331e8fc6a0 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -45,7 +45,7 @@ export namespace ShareNext { }> { const headers: Record = {} - const active = Account.active() + const active = await Account.active() if (!active?.active_org_id) { const baseUrl = await Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai") return { headers, api: legacyApi, baseUrl } diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 67bef3bd38f..b770ab83cb6 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -1 +1,260 @@ -export * from "./skill" +import os from "os" +import path from "path" +import { pathToFileURL } from "url" +import z from "zod" +import { Effect, Layer, ServiceMap } from "effect" +import { NamedError } from "@opencode-ai/util/error" +import type { Agent } from "@/agent/agent" +import { Bus } from "@/bus" +import { InstanceState } from "@/effect/instance-state" +import { makeRunPromise } from "@/effect/run-service" +import { Flag } from "@/flag/flag" +import { Global } from "@/global" +import { Permission } from "@/permission" +import { Filesystem } from "@/util/filesystem" +import { Config } from "../config/config" +import { ConfigMarkdown } from "../config/markdown" +import { Glob } from "../util/glob" +import { Log } from "../util/log" +import { Discovery } from "./discovery" + +export namespace Skill { + const log = Log.create({ service: "skill" }) + const EXTERNAL_DIRS = [".claude", ".agents"] + const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" + const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" + const SKILL_PATTERN = "**/SKILL.md" + + export const Info = z.object({ + name: z.string(), + description: z.string(), + location: z.string(), + content: z.string(), + }) + export type Info = z.infer + + export const InvalidError = NamedError.create( + "SkillInvalidError", + z.object({ + path: z.string(), + message: z.string().optional(), + issues: z.custom().optional(), + }), + ) + + export const NameMismatchError = NamedError.create( + "SkillNameMismatchError", + z.object({ + path: z.string(), + expected: z.string(), + actual: z.string(), + }), + ) + + type State = { + skills: Record + dirs: Set + task?: Promise + } + + type Cache = State & { + ensure: () => Promise + } + + export interface Interface { + readonly get: (name: string) => Effect.Effect + readonly all: () => Effect.Effect + readonly dirs: () => Effect.Effect + readonly available: (agent?: Agent.Info) => Effect.Effect + } + + const add = async (state: State, match: string) => { + const md = await ConfigMarkdown.parse(match).catch(async (err) => { + const message = ConfigMarkdown.FrontmatterError.isInstance(err) + ? err.data.message + : `Failed to parse skill ${match}` + const { Session } = await import("@/session") + Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + log.error("failed to load skill", { skill: match, err }) + return undefined + }) + + if (!md) return + + const parsed = Info.pick({ name: true, description: true }).safeParse(md.data) + if (!parsed.success) return + + if (state.skills[parsed.data.name]) { + log.warn("duplicate skill name", { + name: parsed.data.name, + existing: state.skills[parsed.data.name].location, + duplicate: match, + }) + } + + state.dirs.add(path.dirname(match)) + state.skills[parsed.data.name] = { + name: parsed.data.name, + description: parsed.data.description, + location: match, + content: md.content, + } + } + + const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => { + return Glob.scan(pattern, { + cwd: root, + absolute: true, + include: "file", + symlink: true, + dot: opts?.dot, + }) + .then((matches) => Promise.all(matches.map((match) => add(state, match)))) + .catch((error) => { + if (!opts?.scope) throw error + log.error(`failed to scan ${opts.scope} skills`, { dir: root, error }) + }) + } + + // TODO: Migrate to Effect + const create = (discovery: Discovery.Interface, directory: string, worktree: string): Cache => { + const state: State = { + skills: {}, + dirs: new Set(), + } + + const load = async () => { + if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) { + for (const dir of EXTERNAL_DIRS) { + const root = path.join(Global.Path.home, dir) + if (!(await Filesystem.isDir(root))) continue + await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" }) + } + + for await (const root of Filesystem.up({ + targets: EXTERNAL_DIRS, + start: directory, + stop: worktree, + })) { + await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" }) + } + } + + for (const dir of await Config.directories()) { + await scan(state, dir, OPENCODE_SKILL_PATTERN) + } + + const cfg = await Config.get() + for (const item of cfg.skills?.paths ?? []) { + const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item + const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded) + if (!(await Filesystem.isDir(dir))) { + log.warn("skill path not found", { path: dir }) + continue + } + + await scan(state, dir, SKILL_PATTERN) + } + + for (const url of cfg.skills?.urls ?? []) { + for (const dir of await Effect.runPromise(discovery.pull(url))) { + state.dirs.add(dir) + await scan(state, dir, SKILL_PATTERN) + } + } + + log.info("init", { count: Object.keys(state.skills).length }) + } + + const ensure = () => { + if (state.task) return state.task + state.task = load().catch((err) => { + state.task = undefined + throw err + }) + return state.task + } + + return { ...state, ensure } + } + + export class Service extends ServiceMap.Service()("@opencode/Skill") {} + + export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const discovery = yield* Discovery.Service + const state = yield* InstanceState.make(Effect.fn("Skill.state")((ctx) => Effect.sync(() => create(discovery, ctx.directory, ctx.worktree)))) + + const ensure = Effect.fn("Skill.ensure")(function* () { + const cache = yield* InstanceState.get(state) + yield* Effect.promise(() => cache.ensure()) + return cache + }) + + const get = Effect.fn("Skill.get")(function* (name: string) { + const cache = yield* ensure() + return cache.skills[name] + }) + + const all = Effect.fn("Skill.all")(function* () { + const cache = yield* ensure() + return Object.values(cache.skills) + }) + + const dirs = Effect.fn("Skill.dirs")(function* () { + const cache = yield* ensure() + return Array.from(cache.dirs) + }) + + const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) { + const cache = yield* ensure() + const list = Object.values(cache.skills).toSorted((a, b) => a.name.localeCompare(b.name)) + if (!agent) return list + return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny") + }) + + return Service.of({ get, all, dirs, available }) + }), + ) + + export const defaultLayer: Layer.Layer = layer.pipe(Layer.provide(Discovery.defaultLayer)) + + export function fmt(list: Info[], opts: { verbose: boolean }) { + if (list.length === 0) return "No skills are currently available." + + if (opts.verbose) { + return [ + "", + ...list.flatMap((skill) => [ + " ", + ` ${skill.name}`, + ` ${skill.description}`, + ` ${pathToFileURL(skill.location).href}`, + " ", + ]), + "", + ].join("\n") + } + + return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n") + } + + const runPromise = makeRunPromise(Service, defaultLayer) + + export async function get(name: string) { + return runPromise((skill) => skill.get(name)) + } + + export async function all() { + return runPromise((skill) => skill.all()) + } + + export async function dirs() { + return runPromise((skill) => skill.dirs()) + } + + export async function available(agent?: Agent.Info) { + return runPromise((skill) => skill.available(agent)) + } +} diff --git a/packages/opencode/src/skill/service.ts b/packages/opencode/src/skill/service.ts deleted file mode 100644 index 434a51bad91..00000000000 --- a/packages/opencode/src/skill/service.ts +++ /dev/null @@ -1,238 +0,0 @@ -import os from "os" -import path from "path" -import { pathToFileURL } from "url" -import z from "zod" -import { Effect, Layer, ServiceMap } from "effect" -import { NamedError } from "@opencode-ai/util/error" -import type { Agent } from "@/agent/agent" -import { Bus } from "@/bus" -import { InstanceContext } from "@/effect/instance-context" -import { Flag } from "@/flag/flag" -import { Global } from "@/global" -import { Permission } from "@/permission/service" -import { Filesystem } from "@/util/filesystem" -import { Config } from "../config/config" -import { ConfigMarkdown } from "../config/markdown" -import { Glob } from "../util/glob" -import { Log } from "../util/log" -import { Discovery } from "./discovery" - -export namespace Skill { - const log = Log.create({ service: "skill" }) - const EXTERNAL_DIRS = [".claude", ".agents"] - const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" - const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" - const SKILL_PATTERN = "**/SKILL.md" - - export const Info = z.object({ - name: z.string(), - description: z.string(), - location: z.string(), - content: z.string(), - }) - export type Info = z.infer - - export const InvalidError = NamedError.create( - "SkillInvalidError", - z.object({ - path: z.string(), - message: z.string().optional(), - issues: z.custom().optional(), - }), - ) - - export const NameMismatchError = NamedError.create( - "SkillNameMismatchError", - z.object({ - path: z.string(), - expected: z.string(), - actual: z.string(), - }), - ) - - type State = { - skills: Record - dirs: Set - task?: Promise - } - - type Cache = State & { - ensure: () => Promise - } - - export interface Interface { - readonly get: (name: string) => Effect.Effect - readonly all: () => Effect.Effect - readonly dirs: () => Effect.Effect - readonly available: (agent?: Agent.Info) => Effect.Effect - } - - const add = async (state: State, match: string) => { - const md = await ConfigMarkdown.parse(match).catch(async (err) => { - const message = ConfigMarkdown.FrontmatterError.isInstance(err) - ? err.data.message - : `Failed to parse skill ${match}` - const { Session } = await import("@/session") - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) - log.error("failed to load skill", { skill: match, err }) - return undefined - }) - - if (!md) return - - const parsed = Info.pick({ name: true, description: true }).safeParse(md.data) - if (!parsed.success) return - - if (state.skills[parsed.data.name]) { - log.warn("duplicate skill name", { - name: parsed.data.name, - existing: state.skills[parsed.data.name].location, - duplicate: match, - }) - } - - state.dirs.add(path.dirname(match)) - state.skills[parsed.data.name] = { - name: parsed.data.name, - description: parsed.data.description, - location: match, - content: md.content, - } - } - - const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => { - return Glob.scan(pattern, { - cwd: root, - absolute: true, - include: "file", - symlink: true, - dot: opts?.dot, - }) - .then((matches) => Promise.all(matches.map((match) => add(state, match)))) - .catch((error) => { - if (!opts?.scope) throw error - log.error(`failed to scan ${opts.scope} skills`, { dir: root, error }) - }) - } - - // TODO: Migrate to Effect - const create = (instance: InstanceContext.Shape, discovery: Discovery.Interface): Cache => { - const state: State = { - skills: {}, - dirs: new Set(), - } - - const load = async () => { - if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) { - for (const dir of EXTERNAL_DIRS) { - const root = path.join(Global.Path.home, dir) - if (!(await Filesystem.isDir(root))) continue - await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" }) - } - - for await (const root of Filesystem.up({ - targets: EXTERNAL_DIRS, - start: instance.directory, - stop: instance.project.worktree, - })) { - await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" }) - } - } - - for (const dir of await Config.directories()) { - await scan(state, dir, OPENCODE_SKILL_PATTERN) - } - - const cfg = await Config.get() - for (const item of cfg.skills?.paths ?? []) { - const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item - const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded) - if (!(await Filesystem.isDir(dir))) { - log.warn("skill path not found", { path: dir }) - continue - } - - await scan(state, dir, SKILL_PATTERN) - } - - for (const url of cfg.skills?.urls ?? []) { - for (const dir of await Effect.runPromise(discovery.pull(url))) { - state.dirs.add(dir) - await scan(state, dir, SKILL_PATTERN) - } - } - - log.info("init", { count: Object.keys(state.skills).length }) - } - - const ensure = () => { - if (state.task) return state.task - state.task = load().catch((err) => { - state.task = undefined - throw err - }) - return state.task - } - - return { ...state, ensure } - } - - export class Service extends ServiceMap.Service()("@opencode/Skill") {} - - export const layer: Layer.Layer = Layer.effect( - Service, - Effect.gen(function* () { - const instance = yield* InstanceContext - const discovery = yield* Discovery.Service - const state = create(instance, discovery) - - const get = Effect.fn("Skill.get")(function* (name: string) { - yield* Effect.promise(() => state.ensure()) - return state.skills[name] - }) - - const all = Effect.fn("Skill.all")(function* () { - yield* Effect.promise(() => state.ensure()) - return Object.values(state.skills) - }) - - const dirs = Effect.fn("Skill.dirs")(function* () { - yield* Effect.promise(() => state.ensure()) - return Array.from(state.dirs) - }) - - const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) { - yield* Effect.promise(() => state.ensure()) - const list = Object.values(state.skills).toSorted((a, b) => a.name.localeCompare(b.name)) - if (!agent) return list - return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny") - }) - - return Service.of({ get, all, dirs, available }) - }), - ).pipe(Layer.fresh) - - export const defaultLayer: Layer.Layer = layer.pipe( - Layer.provide(Discovery.defaultLayer), - ) - - export function fmt(list: Info[], opts: { verbose: boolean }) { - if (list.length === 0) return "No skills are currently available." - - if (opts.verbose) { - return [ - "", - ...list.flatMap((skill) => [ - " ", - ` ${skill.name}`, - ` ${skill.description}`, - ` ${pathToFileURL(skill.location).href}`, - " ", - ]), - "", - ].join("\n") - } - - return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n") - } -} diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts deleted file mode 100644 index ed3e0a4b75c..00000000000 --- a/packages/opencode/src/skill/skill.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { runPromiseInstance } from "@/effect/runtime" -import type { Agent } from "@/agent/agent" -import { Skill as S } from "./service" - -export namespace Skill { - export const Info = S.Info - export type Info = S.Info - - export const InvalidError = S.InvalidError - export const NameMismatchError = S.NameMismatchError - - export type Interface = S.Interface - - export const Service = S.Service - export const layer = S.layer - export const defaultLayer = S.defaultLayer - - export const fmt = S.fmt - - export async function get(name: string) { - return runPromiseInstance(S.Service.use((skill) => skill.get(name))) - } - - export async function all() { - return runPromiseInstance(S.Service.use((skill) => skill.all())) - } - - export async function dirs() { - return runPromiseInstance(S.Service.use((skill) => skill.dirs())) - } - - export async function available(agent?: Agent.Info) { - return runPromiseInstance(S.Service.use((skill) => skill.available(agent))) - } -} diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 4f845ca2ded..5f8c5aeffd4 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -1,44 +1,396 @@ -import { runPromiseInstance } from "@/effect/runtime" -import { Snapshot as S } from "./service" +import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" +import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import path from "path" +import z from "zod" +import { InstanceState } from "@/effect/instance-state" +import { makeRunPromise } from "@/effect/run-service" +import { AppFileSystem } from "@/filesystem" +import { Config } from "../config/config" +import { Global } from "../global" +import { Log } from "../util/log" export namespace Snapshot { - export const Patch = S.Patch - export type Patch = S.Patch + export const Patch = z.object({ + hash: z.string(), + files: z.string().array(), + }) + export type Patch = z.infer - export const FileDiff = S.FileDiff - export type FileDiff = S.FileDiff + export const FileDiff = z + .object({ + file: z.string(), + before: z.string(), + after: z.string(), + additions: z.number(), + deletions: z.number(), + status: z.enum(["added", "deleted", "modified"]).optional(), + }) + .meta({ + ref: "FileDiff", + }) + export type FileDiff = z.infer - export type Interface = S.Interface + const log = Log.create({ service: "snapshot" }) + const prune = "7.days" + const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"] + const cfg = ["-c", "core.autocrlf=false", ...core] + const quote = [...cfg, "-c", "core.quotepath=false"] - export const Service = S.Service - export const layer = S.layer - export const defaultLayer = S.defaultLayer + interface GitResult { + readonly code: ChildProcessSpawner.ExitCode + readonly text: string + readonly stderr: string + } + + type State = Omit + + export interface Interface { + readonly init: () => Effect.Effect + readonly cleanup: () => Effect.Effect + readonly track: () => Effect.Effect + readonly patch: (hash: string) => Effect.Effect + readonly restore: (snapshot: string) => Effect.Effect + readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect + readonly diff: (hash: string) => Effect.Effect + readonly diffFull: (from: string, to: string) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/Snapshot") {} + + export const layer: Layer.Layer = + Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const state = yield* InstanceState.make( + Effect.fn("Snapshot.state")(function* (ctx) { + const state = { + directory: ctx.directory, + worktree: ctx.worktree, + gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id), + vcs: ctx.project.vcs, + } + + const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd] + + const git = Effect.fnUntraced( + function* (cmd: string[], opts?: { cwd?: string; env?: Record }) { + const proc = ChildProcess.make("git", cmd, { + cwd: opts?.cwd, + env: opts?.env, + extendEnv: true, + }) + const handle = yield* spawner.spawn(proc) + const [text, stderr] = yield* Effect.all( + [ + Stream.mkString(Stream.decodeText(handle.stdout)), + Stream.mkString(Stream.decodeText(handle.stderr)), + ], + { concurrency: 2 }, + ) + const code = yield* handle.exitCode + return { code, text, stderr } satisfies GitResult + }, + Effect.scoped, + Effect.catch((err) => + Effect.succeed({ + code: ChildProcessSpawner.ExitCode(1), + text: "", + stderr: String(err), + }), + ), + ) + + const exists = (file: string) => fs.exists(file).pipe(Effect.orDie) + const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(""))) + const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void)) + + const enabled = Effect.fnUntraced(function* () { + if (state.vcs !== "git") return false + return (yield* Effect.promise(() => Config.get())).snapshot !== false + }) + + const excludes = Effect.fnUntraced(function* () { + const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], { + cwd: state.worktree, + }) + const file = result.text.trim() + if (!file) return + if (!(yield* exists(file))) return + return file + }) + + const sync = Effect.fnUntraced(function* () { + const file = yield* excludes() + const target = path.join(state.gitdir, "info", "exclude") + yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie) + if (!file) { + yield* fs.writeFileString(target, "").pipe(Effect.orDie) + return + } + yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie) + }) + + const add = Effect.fnUntraced(function* () { + yield* sync() + yield* git([...cfg, ...args(["add", "."])], { cwd: state.directory }) + }) + + const cleanup = Effect.fnUntraced(function* () { + if (!(yield* enabled())) return + if (!(yield* exists(state.gitdir))) return + const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory }) + if (result.code !== 0) { + log.warn("cleanup failed", { + exitCode: result.code, + stderr: result.stderr, + }) + return + } + log.info("cleanup", { prune }) + }) + + const track = Effect.fnUntraced(function* () { + if (!(yield* enabled())) return + const existed = yield* exists(state.gitdir) + yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie) + if (!existed) { + yield* git(["init"], { + env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree }, + }) + yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"]) + yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"]) + yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"]) + yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"]) + log.info("initialized") + } + yield* add() + const result = yield* git(args(["write-tree"]), { cwd: state.directory }) + const hash = result.text.trim() + log.info("tracking", { hash, cwd: state.directory, git: state.gitdir }) + return hash + }) + + const patch = Effect.fnUntraced(function* (hash: string) { + yield* add() + const result = yield* git( + [...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])], + { + cwd: state.directory, + }, + ) + if (result.code !== 0) { + log.warn("failed to get diff", { hash, exitCode: result.code }) + return { hash, files: [] } + } + return { + hash, + files: result.text + .trim() + .split("\n") + .map((x) => x.trim()) + .filter(Boolean) + .map((x) => path.join(state.worktree, x).replaceAll("\\", "/")), + } + }) + + const restore = Effect.fnUntraced(function* (snapshot: string) { + log.info("restore", { commit: snapshot }) + const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree }) + if (result.code === 0) { + const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: state.worktree }) + if (checkout.code === 0) return + log.error("failed to restore snapshot", { + snapshot, + exitCode: checkout.code, + stderr: checkout.stderr, + }) + return + } + log.error("failed to restore snapshot", { + snapshot, + exitCode: result.code, + stderr: result.stderr, + }) + }) + + const revert = Effect.fnUntraced(function* (patches: Snapshot.Patch[]) { + const seen = new Set() + for (const item of patches) { + for (const file of item.files) { + if (seen.has(file)) continue + seen.add(file) + log.info("reverting", { file, hash: item.hash }) + const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], { + cwd: state.worktree, + }) + if (result.code !== 0) { + const rel = path.relative(state.worktree, file) + const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], { + cwd: state.worktree, + }) + if (tree.code === 0 && tree.text.trim()) { + log.info("file existed in snapshot but checkout failed, keeping", { file }) + } else { + log.info("file did not exist in snapshot, deleting", { file }) + yield* remove(file) + } + } + } + } + }) + + const diff = Effect.fnUntraced(function* (hash: string) { + yield* add() + const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], { + cwd: state.worktree, + }) + if (result.code !== 0) { + log.warn("failed to get diff", { + hash, + exitCode: result.code, + stderr: result.stderr, + }) + return "" + } + return result.text.trim() + }) + + const diffFull = Effect.fnUntraced(function* (from: string, to: string) { + const result: Snapshot.FileDiff[] = [] + const status = new Map() + + const statuses = yield* git( + [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])], + { cwd: state.directory }, + ) + + for (const line of statuses.text.trim().split("\n")) { + if (!line) continue + const [code, file] = line.split("\t") + if (!code || !file) continue + status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified") + } + + const numstat = yield* git( + [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])], + { + cwd: state.directory, + }, + ) + + for (const line of numstat.text.trim().split("\n")) { + if (!line) continue + const [adds, dels, file] = line.split("\t") + if (!file) continue + const binary = adds === "-" && dels === "-" + const [before, after] = binary + ? ["", ""] + : yield* Effect.all( + [ + git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)), + git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)), + ], + { concurrency: 2 }, + ) + const additions = binary ? 0 : parseInt(adds) + const deletions = binary ? 0 : parseInt(dels) + result.push({ + file, + before, + after, + additions: Number.isFinite(additions) ? additions : 0, + deletions: Number.isFinite(deletions) ? deletions : 0, + status: status.get(file) ?? "modified", + }) + } + + return result + }) + + yield* cleanup().pipe( + Effect.catchCause((cause) => { + log.error("cleanup loop failed", { cause: Cause.pretty(cause) }) + return Effect.void + }), + Effect.repeat(Schedule.spaced(Duration.hours(1))), + Effect.delay(Duration.minutes(1)), + Effect.forkScoped, + ) + + return { cleanup, track, patch, restore, revert, diff, diffFull } + }), + ) + + return Service.of({ + init: Effect.fn("Snapshot.init")(function* () { + yield* InstanceState.get(state) + }), + cleanup: Effect.fn("Snapshot.cleanup")(function* () { + return yield* InstanceState.useEffect(state, (s) => s.cleanup()) + }), + track: Effect.fn("Snapshot.track")(function* () { + return yield* InstanceState.useEffect(state, (s) => s.track()) + }), + patch: Effect.fn("Snapshot.patch")(function* (hash: string) { + return yield* InstanceState.useEffect(state, (s) => s.patch(hash)) + }), + restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) { + return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot)) + }), + revert: Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) { + return yield* InstanceState.useEffect(state, (s) => s.revert(patches)) + }), + diff: Effect.fn("Snapshot.diff")(function* (hash: string) { + return yield* InstanceState.useEffect(state, (s) => s.diff(hash)) + }), + diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) { + return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to)) + }), + }) + }), + ) + + export const defaultLayer = layer.pipe( + Layer.provide(NodeChildProcessSpawner.layer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner + Layer.provide(NodePath.layer), + ) + + const runPromise = makeRunPromise(Service, defaultLayer) + + export async function init() { + return runPromise((svc) => svc.init()) + } export async function cleanup() { - return runPromiseInstance(S.Service.use((svc) => svc.cleanup())) + return runPromise((svc) => svc.cleanup()) } export async function track() { - return runPromiseInstance(S.Service.use((svc) => svc.track())) + return runPromise((svc) => svc.track()) } export async function patch(hash: string) { - return runPromiseInstance(S.Service.use((svc) => svc.patch(hash))) + return runPromise((svc) => svc.patch(hash)) } export async function restore(snapshot: string) { - return runPromiseInstance(S.Service.use((svc) => svc.restore(snapshot))) + return runPromise((svc) => svc.restore(snapshot)) } export async function revert(patches: Patch[]) { - return runPromiseInstance(S.Service.use((svc) => svc.revert(patches))) + return runPromise((svc) => svc.revert(patches)) } export async function diff(hash: string) { - return runPromiseInstance(S.Service.use((svc) => svc.diff(hash))) + return runPromise((svc) => svc.diff(hash)) } export async function diffFull(from: string, to: string) { - return runPromiseInstance(S.Service.use((svc) => svc.diffFull(from, to))) + return runPromise((svc) => svc.diffFull(from, to)) } } diff --git a/packages/opencode/src/snapshot/service.ts b/packages/opencode/src/snapshot/service.ts deleted file mode 100644 index 50485d0a7f3..00000000000 --- a/packages/opencode/src/snapshot/service.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" -import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect" -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" -import path from "path" -import z from "zod" -import { InstanceContext } from "@/effect/instance-context" -import { AppFileSystem } from "@/filesystem" -import { Config } from "../config/config" -import { Global } from "../global" -import { Log } from "../util/log" - -export namespace Snapshot { - export const Patch = z.object({ - hash: z.string(), - files: z.string().array(), - }) - export type Patch = z.infer - - export const FileDiff = z - .object({ - file: z.string(), - before: z.string(), - after: z.string(), - additions: z.number(), - deletions: z.number(), - status: z.enum(["added", "deleted", "modified"]).optional(), - }) - .meta({ - ref: "FileDiff", - }) - export type FileDiff = z.infer - - const log = Log.create({ service: "snapshot" }) - const prune = "7.days" - const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"] - const cfg = ["-c", "core.autocrlf=false", ...core] - const quote = [...cfg, "-c", "core.quotepath=false"] - - interface GitResult { - readonly code: ChildProcessSpawner.ExitCode - readonly text: string - readonly stderr: string - } - - export interface Interface { - readonly cleanup: () => Effect.Effect - readonly track: () => Effect.Effect - readonly patch: (hash: string) => Effect.Effect - readonly restore: (snapshot: string) => Effect.Effect - readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect - readonly diff: (hash: string) => Effect.Effect - readonly diffFull: (from: string, to: string) => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@opencode/Snapshot") {} - - export const layer: Layer.Layer< - Service, - never, - InstanceContext | AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner - > = Layer.effect( - Service, - Effect.gen(function* () { - const ctx = yield* InstanceContext - const fs = yield* AppFileSystem.Service - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - const directory = ctx.directory - const worktree = ctx.worktree - const project = ctx.project - const gitdir = path.join(Global.Path.data, "snapshot", project.id) - - const args = (cmd: string[]) => ["--git-dir", gitdir, "--work-tree", worktree, ...cmd] - - const git = Effect.fnUntraced( - function* (cmd: string[], opts?: { cwd?: string; env?: Record }) { - const proc = ChildProcess.make("git", cmd, { - cwd: opts?.cwd, - env: opts?.env, - extendEnv: true, - }) - const handle = yield* spawner.spawn(proc) - const [text, stderr] = yield* Effect.all( - [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], - { concurrency: 2 }, - ) - const code = yield* handle.exitCode - return { code, text, stderr } satisfies GitResult - }, - Effect.scoped, - Effect.catch((err) => - Effect.succeed({ - code: ChildProcessSpawner.ExitCode(1), - text: "", - stderr: String(err), - }), - ), - ) - - // Snapshot-specific error handling on top of AppFileSystem - const exists = (file: string) => fs.exists(file).pipe(Effect.orDie) - const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(""))) - const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void)) - - const enabled = Effect.fnUntraced(function* () { - if (project.vcs !== "git") return false - return (yield* Effect.promise(() => Config.get())).snapshot !== false - }) - - const excludes = Effect.fnUntraced(function* () { - const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], { - cwd: worktree, - }) - const file = result.text.trim() - if (!file) return - if (!(yield* exists(file))) return - return file - }) - - const sync = Effect.fnUntraced(function* () { - const file = yield* excludes() - const target = path.join(gitdir, "info", "exclude") - yield* fs.ensureDir(path.join(gitdir, "info")).pipe(Effect.orDie) - if (!file) { - yield* fs.writeFileString(target, "").pipe(Effect.orDie) - return - } - yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie) - }) - - const add = Effect.fnUntraced(function* () { - yield* sync() - yield* git([...cfg, ...args(["add", "."])], { cwd: directory }) - }) - - const cleanup = Effect.fn("Snapshot.cleanup")(function* () { - if (!(yield* enabled())) return - if (!(yield* exists(gitdir))) return - const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: directory }) - if (result.code !== 0) { - log.warn("cleanup failed", { - exitCode: result.code, - stderr: result.stderr, - }) - return - } - log.info("cleanup", { prune }) - }) - - const track = Effect.fn("Snapshot.track")(function* () { - if (!(yield* enabled())) return - const existed = yield* exists(gitdir) - yield* fs.ensureDir(gitdir).pipe(Effect.orDie) - if (!existed) { - yield* git(["init"], { - env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree }, - }) - yield* git(["--git-dir", gitdir, "config", "core.autocrlf", "false"]) - yield* git(["--git-dir", gitdir, "config", "core.longpaths", "true"]) - yield* git(["--git-dir", gitdir, "config", "core.symlinks", "true"]) - yield* git(["--git-dir", gitdir, "config", "core.fsmonitor", "false"]) - log.info("initialized") - } - yield* add() - const result = yield* git(args(["write-tree"]), { cwd: directory }) - const hash = result.text.trim() - log.info("tracking", { hash, cwd: directory, git: gitdir }) - return hash - }) - - const patch = Effect.fn("Snapshot.patch")(function* (hash: string) { - yield* add() - const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])], { - cwd: directory, - }) - if (result.code !== 0) { - log.warn("failed to get diff", { hash, exitCode: result.code }) - return { hash, files: [] } - } - return { - hash, - files: result.text - .trim() - .split("\n") - .map((x) => x.trim()) - .filter(Boolean) - .map((x) => path.join(worktree, x).replaceAll("\\", "/")), - } - }) - - const restore = Effect.fn("Snapshot.restore")(function* (snapshot: string) { - log.info("restore", { commit: snapshot }) - const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: worktree }) - if (result.code === 0) { - const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: worktree }) - if (checkout.code === 0) return - log.error("failed to restore snapshot", { - snapshot, - exitCode: checkout.code, - stderr: checkout.stderr, - }) - return - } - log.error("failed to restore snapshot", { - snapshot, - exitCode: result.code, - stderr: result.stderr, - }) - }) - - const revert = Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) { - const seen = new Set() - for (const item of patches) { - for (const file of item.files) { - if (seen.has(file)) continue - seen.add(file) - log.info("reverting", { file, hash: item.hash }) - const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], { cwd: worktree }) - if (result.code !== 0) { - const rel = path.relative(worktree, file) - const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], { cwd: worktree }) - if (tree.code === 0 && tree.text.trim()) { - log.info("file existed in snapshot but checkout failed, keeping", { file }) - } else { - log.info("file did not exist in snapshot, deleting", { file }) - yield* remove(file) - } - } - } - } - }) - - const diff = Effect.fn("Snapshot.diff")(function* (hash: string) { - yield* add() - const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], { - cwd: worktree, - }) - if (result.code !== 0) { - log.warn("failed to get diff", { - hash, - exitCode: result.code, - stderr: result.stderr, - }) - return "" - } - return result.text.trim() - }) - - const diffFull = Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) { - const result: Snapshot.FileDiff[] = [] - const status = new Map() - - const statuses = yield* git( - [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])], - { cwd: directory }, - ) - - for (const line of statuses.text.trim().split("\n")) { - if (!line) continue - const [code, file] = line.split("\t") - if (!code || !file) continue - status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified") - } - - const numstat = yield* git( - [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])], - { - cwd: directory, - }, - ) - - for (const line of numstat.text.trim().split("\n")) { - if (!line) continue - const [adds, dels, file] = line.split("\t") - if (!file) continue - const binary = adds === "-" && dels === "-" - const [before, after] = binary - ? ["", ""] - : yield* Effect.all( - [ - git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)), - git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)), - ], - { concurrency: 2 }, - ) - const additions = binary ? 0 : parseInt(adds) - const deletions = binary ? 0 : parseInt(dels) - result.push({ - file, - before, - after, - additions: Number.isFinite(additions) ? additions : 0, - deletions: Number.isFinite(deletions) ? deletions : 0, - status: status.get(file) ?? "modified", - }) - } - - return result - }) - - yield* cleanup().pipe( - Effect.catchCause((cause) => { - log.error("cleanup loop failed", { cause: Cause.pretty(cause) }) - return Effect.void - }), - Effect.repeat(Schedule.spaced(Duration.hours(1))), - Effect.delay(Duration.minutes(1)), - Effect.forkScoped, - ) - - return Service.of({ cleanup, track, patch, restore, revert, diff, diffFull }) - }), - ).pipe(Layer.fresh) - - export const defaultLayer = layer.pipe( - Layer.provide(NodeChildProcessSpawner.layer), - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner - Layer.provide(NodePath.layer), - ) -} diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 66c8b181b25..06293b6eba6 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -12,7 +12,7 @@ import { trimDiff } from "./edit" import { LSP } from "../lsp" import { Filesystem } from "../util/filesystem" import DESCRIPTION from "./apply_patch.txt" -import { File } from "../file/service" +import { File } from "../file" const PatchParams = z.object({ patchText: z.string().describe("The full patch text that describes all changes to be made"), diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 982095cd55f..1a7614fc17f 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -9,13 +9,13 @@ import { Tool } from "./tool" import { LSP } from "../lsp" import { createTwoFilesPatch, diffLines } from "diff" import DESCRIPTION from "./edit.txt" -import { File } from "../file/service" +import { File } from "../file" import { FileWatcher } from "../file/watcher" import { Bus } from "../bus" import { FileTime } from "../file/time" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" -import { Snapshot } from "@/snapshot/service" +import { Snapshot } from "@/snapshot" import { assertExternalDirectory } from "./external-directory" const MAX_DIAGNOSTICS_PER_FILE = 20 diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts index 27a988e5632..a2887546d4b 100644 --- a/packages/opencode/src/tool/question.ts +++ b/packages/opencode/src/tool/question.ts @@ -1,7 +1,6 @@ import z from "zod" import { Tool } from "./tool" -import { Question } from "../question/service" -import { Question as QuestionApi } from "../question" +import { Question } from "../question" import DESCRIPTION from "./question.txt" export const QuestionTool = Tool.define("question", { @@ -10,7 +9,7 @@ export const QuestionTool = Tool.define("question", { questions: z.array(Question.Info.omit({ custom: true })).describe("Questions to ask"), }), async execute(params, ctx) { - const answers = await QuestionApi.ask({ + const answers = await Question.ask({ sessionID: ctx.sessionID, questions: params.questions, tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 79bec756026..e3781126d0c 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -10,7 +10,7 @@ import { SessionPrompt } from "../session/prompt" import { iife } from "@/util/iife" import { defer } from "@/util/defer" import { Config } from "../config/config" -import { Permission as PermissionNext } from "@/permission/service" +import { Permission } from "@/permission" const parameters = z.object({ description: z.string().describe("A short (3-5 words) description of the task"), @@ -31,7 +31,7 @@ export const TaskTool = Tool.define("task", async (ctx) => { // Filter agents by permissions if agent provided const caller = ctx?.agent const accessibleAgents = caller - ? agents.filter((a) => PermissionNext.evaluate("task", a.name, caller.permission).action !== "deny") + ? agents.filter((a) => Permission.evaluate("task", a.name, caller.permission).action !== "deny") : agents const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name)) diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index c34bdbc5085..6c3f4efaf6d 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -1,7 +1,7 @@ import z from "zod" import type { MessageV2 } from "../session/message-v2" import type { Agent } from "../agent/agent" -import type { Permission as PermissionNext } from "../permission/service" +import type { Permission } from "../permission" import type { SessionID, MessageID } from "../session/schema" import { Truncate } from "./truncate" @@ -23,7 +23,7 @@ export namespace Tool { extra?: { [key: string]: any } messages: MessageV2.WithParts[] metadata(input: { title?: string; metadata?: M }): void - ask(input: Omit): Promise + ask(input: Omit): Promise } export interface Info { id: string diff --git a/packages/opencode/src/tool/truncate-effect.ts b/packages/opencode/src/tool/truncate-effect.ts deleted file mode 100644 index 1b4c6577f3b..00000000000 --- a/packages/opencode/src/tool/truncate-effect.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { NodePath } from "@effect/platform-node" -import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect" -import path from "path" -import type { Agent } from "../agent/agent" -import { AppFileSystem } from "@/filesystem" -import { evaluate } from "@/permission/evaluate" -import { Identifier } from "../id/id" -import { Log } from "../util/log" -import { ToolID } from "./schema" -import { TRUNCATION_DIR } from "./truncation-dir" - -export namespace Truncate { - const log = Log.create({ service: "truncation" }) - const RETENTION = Duration.days(7) - - export const MAX_LINES = 2000 - export const MAX_BYTES = 50 * 1024 - export const DIR = TRUNCATION_DIR - export const GLOB = path.join(TRUNCATION_DIR, "*") - - export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string } - - export interface Options { - maxLines?: number - maxBytes?: number - direction?: "head" | "tail" - } - - function hasTaskTool(agent?: Agent.Info) { - if (!agent?.permission) return false - return evaluate("task", "*", agent.permission).action !== "deny" - } - - export interface Interface { - readonly cleanup: () => Effect.Effect - /** - * Returns output unchanged when it fits within the limits, otherwise writes the full text - * to the truncation directory and returns a preview plus a hint to inspect the saved file. - */ - readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@opencode/Truncate") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - - const cleanup = Effect.fn("Truncate.cleanup")(function* () { - const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION))) - const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe( - Effect.map((all) => all.filter((name) => name.startsWith("tool_"))), - Effect.catch(() => Effect.succeed([])), - ) - for (const entry of entries) { - if (Identifier.timestamp(entry) >= cutoff) continue - yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void)) - } - }) - - const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) { - const maxLines = options.maxLines ?? MAX_LINES - const maxBytes = options.maxBytes ?? MAX_BYTES - const direction = options.direction ?? "head" - const lines = text.split("\n") - const totalBytes = Buffer.byteLength(text, "utf-8") - - if (lines.length <= maxLines && totalBytes <= maxBytes) { - return { content: text, truncated: false } as const - } - - const out: string[] = [] - let i = 0 - let bytes = 0 - let hitBytes = false - - if (direction === "head") { - for (i = 0; i < lines.length && i < maxLines; i++) { - const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0) - if (bytes + size > maxBytes) { - hitBytes = true - break - } - out.push(lines[i]) - bytes += size - } - } else { - for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) { - const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0) - if (bytes + size > maxBytes) { - hitBytes = true - break - } - out.unshift(lines[i]) - bytes += size - } - } - - const removed = hitBytes ? totalBytes - bytes : lines.length - out.length - const unit = hitBytes ? "bytes" : "lines" - const preview = out.join("\n") - const file = path.join(TRUNCATION_DIR, ToolID.ascending()) - - yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie) - yield* fs.writeFileString(file, text).pipe(Effect.orDie) - - const hint = hasTaskTool(agent) - ? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.` - : `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.` - - return { - content: - direction === "head" - ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}` - : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`, - truncated: true, - outputPath: file, - } as const - }) - - yield* cleanup().pipe( - Effect.catchCause((cause) => { - log.error("truncation cleanup failed", { cause: Cause.pretty(cause) }) - return Effect.void - }), - Effect.repeat(Schedule.spaced(Duration.hours(1))), - Effect.delay(Duration.minutes(1)), - Effect.forkScoped, - ) - - return Service.of({ cleanup, output }) - }), - ) - - export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer)) -} diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index 17105463837..fa1d0a4aed6 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -1,18 +1,144 @@ +import { NodePath } from "@effect/platform-node" +import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect" +import path from "path" import type { Agent } from "../agent/agent" -import { runtime } from "@/effect/runtime" -import { Truncate as S } from "./truncate-effect" +import { makeRunPromise } from "@/effect/run-service" +import { AppFileSystem } from "@/filesystem" +import { evaluate } from "@/permission/evaluate" +import { Identifier } from "../id/id" +import { Log } from "../util/log" +import { ToolID } from "./schema" +import { TRUNCATION_DIR } from "./truncation-dir" export namespace Truncate { - export const MAX_LINES = S.MAX_LINES - export const MAX_BYTES = S.MAX_BYTES - export const DIR = S.DIR - export const GLOB = S.GLOB + const log = Log.create({ service: "truncation" }) + const RETENTION = Duration.days(7) - export type Result = S.Result + export const MAX_LINES = 2000 + export const MAX_BYTES = 50 * 1024 + export const DIR = TRUNCATION_DIR + export const GLOB = path.join(TRUNCATION_DIR, "*") - export type Options = S.Options + export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string } + + export interface Options { + maxLines?: number + maxBytes?: number + direction?: "head" | "tail" + } + + function hasTaskTool(agent?: Agent.Info) { + if (!agent?.permission) return false + return evaluate("task", "*", agent.permission).action !== "deny" + } + + export interface Interface { + readonly cleanup: () => Effect.Effect + /** + * Returns output unchanged when it fits within the limits, otherwise writes the full text + * to the truncation directory and returns a preview plus a hint to inspect the saved file. + */ + readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/Truncate") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + + const cleanup = Effect.fn("Truncate.cleanup")(function* () { + const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION))) + const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe( + Effect.map((all) => all.filter((name) => name.startsWith("tool_"))), + Effect.catch(() => Effect.succeed([])), + ) + for (const entry of entries) { + if (Identifier.timestamp(entry) >= cutoff) continue + yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void)) + } + }) + + const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) { + const maxLines = options.maxLines ?? MAX_LINES + const maxBytes = options.maxBytes ?? MAX_BYTES + const direction = options.direction ?? "head" + const lines = text.split("\n") + const totalBytes = Buffer.byteLength(text, "utf-8") + + if (lines.length <= maxLines && totalBytes <= maxBytes) { + return { content: text, truncated: false } as const + } + + const out: string[] = [] + let i = 0 + let bytes = 0 + let hitBytes = false + + if (direction === "head") { + for (i = 0; i < lines.length && i < maxLines; i++) { + const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0) + if (bytes + size > maxBytes) { + hitBytes = true + break + } + out.push(lines[i]) + bytes += size + } + } else { + for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) { + const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0) + if (bytes + size > maxBytes) { + hitBytes = true + break + } + out.unshift(lines[i]) + bytes += size + } + } + + const removed = hitBytes ? totalBytes - bytes : lines.length - out.length + const unit = hitBytes ? "bytes" : "lines" + const preview = out.join("\n") + const file = path.join(TRUNCATION_DIR, ToolID.ascending()) + + yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie) + yield* fs.writeFileString(file, text).pipe(Effect.orDie) + + const hint = hasTaskTool(agent) + ? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.` + : `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.` + + return { + content: + direction === "head" + ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}` + : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`, + truncated: true, + outputPath: file, + } as const + }) + + yield* cleanup().pipe( + Effect.catchCause((cause) => { + log.error("truncation cleanup failed", { cause: Cause.pretty(cause) }) + return Effect.void + }), + Effect.repeat(Schedule.spaced(Duration.hours(1))), + Effect.delay(Duration.minutes(1)), + Effect.forkScoped, + ) + + return Service.of({ cleanup, output }) + }), + ) + + export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer)) + + const runPromise = makeRunPromise(Service, defaultLayer) export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise { - return runtime.runPromise(S.Service.use((s) => s.output(text, options, agent))) + return runPromise((s) => s.output(text, options, agent)) } } diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index abfab6d482a..83474a543ca 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -5,7 +5,7 @@ import { LSP } from "../lsp" import { createTwoFilesPatch } from "diff" import DESCRIPTION from "./write.txt" import { Bus } from "../bus" -import { File } from "../file/service" +import { File } from "../file" import { FileWatcher } from "../file/watcher" import { FileTime } from "../file/time" import { Filesystem } from "../util/filesystem" diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts index f5436e5147a..9c67641d208 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 { Account } from "../../src/account/effect" +import { Account } from "../../src/account" import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema" import { Database } from "../../src/storage/db" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 60c8e57c926..2805cf2614e 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -1,16 +1,20 @@ -import { test, expect } from "bun:test" +import { afterEach, test, expect } from "bun:test" import path from "path" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Agent } from "../../src/agent/agent" -import { PermissionNext } from "../../src/permission" +import { Permission } from "../../src/permission" // Helper to evaluate permission for a tool with wildcard pattern -function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined { +function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined { if (!agent) return undefined - return PermissionNext.evaluate(permission, "*", agent.permission).action + return Permission.evaluate(permission, "*", agent.permission).action } +afterEach(async () => { + await Instance.disposeAll() +}) + test("returns default native agents when no config", async () => { await using tmp = await tmpdir() await Instance.provide({ @@ -54,7 +58,7 @@ test("plan agent denies edits except .opencode/plans/*", async () => { // Wildcard is denied expect(evalPerm(plan, "edit")).toBe("deny") // But specific path is allowed - expect(PermissionNext.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow") + expect(Permission.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow") }, }) }) @@ -83,8 +87,8 @@ test("explore agent asks for external directories and allows Truncate.GLOB", asy fn: async () => { const explore = await Agent.get("explore") expect(explore).toBeDefined() - expect(PermissionNext.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask") - expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow") + expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask") + expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow") }, }) }) @@ -216,7 +220,7 @@ test("agent permission config merges with defaults", async () => { const build = await Agent.get("build") expect(build).toBeDefined() // Specific pattern is denied - expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny") + expect(Permission.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny") // Edit still allowed expect(evalPerm(build, "edit")).toBe("allow") }, @@ -501,9 +505,9 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally directory: tmp.path, fn: async () => { const build = await Agent.get("build") - expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow") - expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") - expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny") + expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow") + expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") + expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny") }, }) }) @@ -525,9 +529,9 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen directory: tmp.path, fn: async () => { const build = await Agent.get("build") - expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow") - expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") - expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny") + expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow") + expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") + expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny") }, }) }) @@ -548,8 +552,8 @@ test("explicit Truncate.GLOB deny is respected", async () => { directory: tmp.path, fn: async () => { const build = await Agent.get("build") - expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny") - expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") + expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny") + expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") }, }) }) @@ -582,7 +586,7 @@ description: Permission skill. const build = await Agent.get("build") const skillDir = path.join(tmp.path, ".opencode", "skill", "perm-skill") const target = path.join(skillDir, "reference", "notes.md") - expect(PermissionNext.evaluate("external_directory", target, build!.permission).action).toBe("allow") + expect(Permission.evaluate("external_directory", target, build!.permission).action).toBe("allow") }, }) } finally { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index baf209d8607..eb9c763fa75 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -251,7 +251,7 @@ test("resolves env templates in account config with account token", async () => const originalToken = Account.token const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"] - Account.active = mock(() => ({ + Account.active = mock(async () => ({ id: AccountID.make("account-1"), email: "user@example.com", url: "https://control.example.com", diff --git a/packages/opencode/test/effect/instance-state.test.ts b/packages/opencode/test/effect/instance-state.test.ts new file mode 100644 index 00000000000..2d527482ba1 --- /dev/null +++ b/packages/opencode/test/effect/instance-state.test.ts @@ -0,0 +1,384 @@ +import { afterEach, expect, test } from "bun:test" +import { Duration, Effect, Layer, ManagedRuntime, ServiceMap } from "effect" +import { InstanceState } from "../../src/effect/instance-state" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +async function access(state: InstanceState, dir: string) { + return Instance.provide({ + directory: dir, + fn: () => Effect.runPromise(InstanceState.get(state)), + }) +} + +afterEach(async () => { + await Instance.disposeAll() +}) + +test("InstanceState caches values per directory", async () => { + await using tmp = await tmpdir() + let n = 0 + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const state = yield* InstanceState.make(() => 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 directories", async () => { + await using one = await tmpdir() + await using two = await tmpdir() + let n = 0 + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n }))) + + const a = yield* Effect.promise(() => access(state, one.path)) + const b = yield* Effect.promise(() => access(state, two.path)) + const c = yield* Effect.promise(() => access(state, one.path)) + + expect(a).toBe(c) + expect(a).not.toBe(b) + expect(n).toBe(2) + }), + ), + ) +}) + +test("InstanceState invalidates on 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(() => + Effect.acquireRelease( + Effect.sync(() => ({ n: ++n })), + (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 invalidates on disposeAll", async () => { + await using one = await tmpdir() + await using two = await tmpdir() + const seen: string[] = [] + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const state = yield* InstanceState.make((ctx) => + Effect.acquireRelease( + Effect.sync(() => ({ dir: ctx.directory })), + (value) => + Effect.sync(() => { + seen.push(value.dir) + }), + ), + ) + + yield* Effect.promise(() => access(state, one.path)) + yield* Effect.promise(() => access(state, two.path)) + yield* Effect.promise(() => Instance.disposeAll()) + + expect(seen.sort()).toEqual([one.path, two.path].sort()) + }), + ), + ) +}) + +test("InstanceState.get reads the current directory lazily", async () => { + await using one = await tmpdir() + await using two = await tmpdir() + + interface Api { + readonly get: () => Effect.Effect + } + + class Test extends ServiceMap.Service()("@test/InstanceStateLazy") { + static readonly layer = Layer.effect( + Test, + Effect.gen(function* () { + const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory)) + const get = InstanceState.get(state) + + return Test.of({ + get: Effect.fn("Test.get")(function* () { + return yield* get + }), + }) + }), + ) + } + + const rt = ManagedRuntime.make(Test.layer) + + try { + const a = await Instance.provide({ + directory: one.path, + fn: () => rt.runPromise(Test.use((svc) => svc.get())), + }) + const b = await Instance.provide({ + directory: two.path, + fn: () => rt.runPromise(Test.use((svc) => svc.get())), + }) + + expect(a).toBe(one.path) + expect(b).toBe(two.path) + } finally { + await rt.dispose() + } +}) + +test("InstanceState preserves directory across async boundaries", async () => { + await using one = await tmpdir({ git: true }) + await using two = await tmpdir({ git: true }) + await using three = await tmpdir({ git: true }) + + interface Api { + readonly get: () => Effect.Effect<{ directory: string; worktree: string; project: string }> + } + + class Test extends ServiceMap.Service()("@test/InstanceStateAsync") { + static readonly layer = Layer.effect( + Test, + Effect.gen(function* () { + const state = yield* InstanceState.make((ctx) => + Effect.sync(() => ({ + directory: ctx.directory, + worktree: ctx.worktree, + project: ctx.project.id, + })), + ) + + return Test.of({ + get: Effect.fn("Test.get")(function* () { + yield* Effect.promise(() => Bun.sleep(1)) + yield* Effect.sleep(Duration.millis(1)) + for (let i = 0; i < 100; i++) { + yield* Effect.yieldNow + } + for (let i = 0; i < 100; i++) { + yield* Effect.promise(() => Promise.resolve()) + } + yield* Effect.sleep(Duration.millis(2)) + yield* Effect.promise(() => Bun.sleep(1)) + return yield* InstanceState.get(state) + }), + }) + }), + ) + } + + const rt = ManagedRuntime.make(Test.layer) + + try { + const [a, b, c] = await Promise.all([ + Instance.provide({ + directory: one.path, + fn: () => rt.runPromise(Test.use((svc) => svc.get())), + }), + Instance.provide({ + directory: two.path, + fn: () => rt.runPromise(Test.use((svc) => svc.get())), + }), + Instance.provide({ + directory: three.path, + fn: () => rt.runPromise(Test.use((svc) => svc.get())), + }), + ]) + + expect(a).toEqual({ directory: one.path, worktree: one.path, project: a.project }) + expect(b).toEqual({ directory: two.path, worktree: two.path, project: b.project }) + expect(c).toEqual({ directory: three.path, worktree: three.path, project: c.project }) + expect(a.project).not.toBe(b.project) + expect(a.project).not.toBe(c.project) + expect(b.project).not.toBe(c.project) + } finally { + await rt.dispose() + } +}) + +test("InstanceState survives high-contention concurrent access", async () => { + const N = 20 + const dirs = await Promise.all(Array.from({ length: N }, () => tmpdir())) + + interface Api { + readonly get: () => Effect.Effect + } + + class Test extends ServiceMap.Service()("@test/HighContention") { + static readonly layer = Layer.effect( + Test, + Effect.gen(function* () { + const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory)) + + return Test.of({ + get: Effect.fn("Test.get")(function* () { + // Interleave many async hops to maximize chance of ALS corruption + for (let i = 0; i < 10; i++) { + yield* Effect.promise(() => Bun.sleep(Math.random() * 3)) + yield* Effect.yieldNow + yield* Effect.promise(() => Promise.resolve()) + } + return yield* InstanceState.get(state) + }), + }) + }), + ) + } + + const rt = ManagedRuntime.make(Test.layer) + + try { + const results = await Promise.all( + dirs.map((d) => + Instance.provide({ + directory: d.path, + fn: () => rt.runPromise(Test.use((svc) => svc.get())), + }), + ), + ) + + for (let i = 0; i < N; i++) { + expect(results[i]).toBe(dirs[i].path) + } + } finally { + await rt.dispose() + for (const d of dirs) await d[Symbol.asyncDispose]() + } +}) + +test("InstanceState correct after interleaved init and dispose", async () => { + await using one = await tmpdir() + await using two = await tmpdir() + + interface Api { + readonly get: () => Effect.Effect + } + + class Test extends ServiceMap.Service()("@test/InterleavedDispose") { + static readonly layer = Layer.effect( + Test, + Effect.gen(function* () { + const state = yield* InstanceState.make((ctx) => + Effect.promise(async () => { + await Bun.sleep(5) // slow init + return ctx.directory + }), + ) + + return Test.of({ + get: Effect.fn("Test.get")(function* () { + return yield* InstanceState.get(state) + }), + }) + }), + ) + } + + const rt = ManagedRuntime.make(Test.layer) + + try { + // Init both directories + const a = await Instance.provide({ + directory: one.path, + fn: () => rt.runPromise(Test.use((svc) => svc.get())), + }) + expect(a).toBe(one.path) + + // Dispose one directory, access the other concurrently + const [, b] = await Promise.all([ + Instance.reload({ directory: one.path }), + Instance.provide({ + directory: two.path, + fn: () => rt.runPromise(Test.use((svc) => svc.get())), + }), + ]) + expect(b).toBe(two.path) + + // Re-access disposed directory - should get fresh state + const c = await Instance.provide({ + directory: one.path, + fn: () => rt.runPromise(Test.use((svc) => svc.get())), + }) + expect(c).toBe(one.path) + } finally { + await rt.dispose() + } +}) + +test("InstanceState mutation in one directory does not leak to another", async () => { + await using one = await tmpdir() + await using two = await tmpdir() + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const state = yield* InstanceState.make(() => Effect.sync(() => ({ count: 0 }))) + + // Mutate state in directory one + const s1 = yield* Effect.promise(() => access(state, one.path)) + s1.count = 42 + + // Access directory two — should be independent + const s2 = yield* Effect.promise(() => access(state, two.path)) + expect(s2.count).toBe(0) + + // Confirm directory one still has the mutation + const s1again = yield* Effect.promise(() => access(state, one.path)) + expect(s1again.count).toBe(42) + expect(s1again).toBe(s1) // same reference + }), + ), + ) +}) + +test("InstanceState dedupes concurrent lookups", async () => { + await using tmp = await tmpdir() + let n = 0 + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const state = yield* InstanceState.make(() => + 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/effect/run-service.test.ts b/packages/opencode/test/effect/run-service.test.ts new file mode 100644 index 00000000000..c9f630585ed --- /dev/null +++ b/packages/opencode/test/effect/run-service.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test" +import { Effect, Layer, ServiceMap } from "effect" +import { makeRunPromise } from "../../src/effect/run-service" + +class Shared extends ServiceMap.Service()("@test/Shared") {} + +test("makeRunPromise shares dependent layers through the shared memo map", async () => { + let n = 0 + + const shared = Layer.effect( + Shared, + Effect.sync(() => { + n += 1 + return Shared.of({ id: n }) + }), + ) + + class One extends ServiceMap.Service Effect.Effect }>()("@test/One") {} + const one = Layer.effect( + One, + Effect.gen(function* () { + const svc = yield* Shared + return One.of({ + get: Effect.fn("One.get")(() => Effect.succeed(svc.id)), + }) + }), + ).pipe(Layer.provide(shared)) + + class Two extends ServiceMap.Service Effect.Effect }>()("@test/Two") {} + const two = Layer.effect( + Two, + Effect.gen(function* () { + const svc = yield* Shared + return Two.of({ + get: Effect.fn("Two.get")(() => Effect.succeed(svc.id)), + }) + }), + ).pipe(Layer.provide(shared)) + + const runOne = makeRunPromise(One, one) + const runTwo = makeRunPromise(Two, two) + + expect(await runOne((svc) => svc.get())).toBe(1) + expect(await runTwo((svc) => svc.get())).toBe(1) + expect(n).toBe(1) +}) diff --git a/packages/opencode/test/effect/runtime.test.ts b/packages/opencode/test/effect/runtime.test.ts deleted file mode 100644 index 70bf29aaf3d..00000000000 --- a/packages/opencode/test/effect/runtime.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test" -import { Effect } from "effect" -import { runtime, runPromiseInstance } from "../../src/effect/runtime" -import { Auth } from "../../src/auth/effect" -import { Instances } from "../../src/effect/instances" -import { Instance } from "../../src/project/instance" -import { ProviderAuth } from "../../src/provider/auth" -import { Vcs } from "../../src/project/vcs" -import { Question } from "../../src/question" -import { tmpdir } from "../fixture/fixture" - -/** - * Integration tests for the Effect runtime and LayerMap-based instance system. - * - * Each instance service layer has `.pipe(Layer.fresh)` at its definition site - * so it is always rebuilt per directory, while shared dependencies are provided - * outside the fresh boundary and remain memoizable. - * - * These tests verify the invariants using object identity (===) on the real - * production services — not mock services or return-value checks. - */ - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const grabInstance = (service: any) => runPromiseInstance(service.use(Effect.succeed)) -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const grabGlobal = (service: any) => runtime.runPromise(service.use(Effect.succeed)) - -describe("effect/runtime", () => { - afterEach(async () => { - await Instance.disposeAll() - }) - - test("global services are shared across directories", async () => { - await using one = await tmpdir({ git: true }) - await using two = await tmpdir({ git: true }) - - // Auth is a global service — it should be the exact same object - // regardless of which directory we're in. - const authOne = await Instance.provide({ - directory: one.path, - fn: () => grabGlobal(Auth.Service), - }) - - const authTwo = await Instance.provide({ - directory: two.path, - fn: () => grabGlobal(Auth.Service), - }) - - expect(authOne).toBe(authTwo) - }) - - test("instance services with global deps share the global (ProviderAuth → Auth)", async () => { - await using one = await tmpdir({ git: true }) - await using two = await tmpdir({ git: true }) - - // ProviderAuth depends on Auth via defaultLayer. - // The instance service itself should be different per directory, - // but the underlying Auth should be shared. - const paOne = await Instance.provide({ - directory: one.path, - fn: () => grabInstance(ProviderAuth.Service), - }) - - const paTwo = await Instance.provide({ - directory: two.path, - fn: () => grabInstance(ProviderAuth.Service), - }) - - // Different directories → different ProviderAuth instances. - expect(paOne).not.toBe(paTwo) - - // But the global Auth is the same object in both. - const authOne = await Instance.provide({ - directory: one.path, - fn: () => grabGlobal(Auth.Service), - }) - const authTwo = await Instance.provide({ - directory: two.path, - fn: () => grabGlobal(Auth.Service), - }) - expect(authOne).toBe(authTwo) - }) - - test("instance services are shared within the same directory", async () => { - await using tmp = await tmpdir({ git: true }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - expect(await grabInstance(Vcs.Service)).toBe(await grabInstance(Vcs.Service)) - expect(await grabInstance(Question.Service)).toBe(await grabInstance(Question.Service)) - }, - }) - }) - - test("different directories get different service instances", async () => { - await using one = await tmpdir({ git: true }) - await using two = await tmpdir({ git: true }) - - const vcsOne = await Instance.provide({ - directory: one.path, - fn: () => grabInstance(Vcs.Service), - }) - - const vcsTwo = await Instance.provide({ - directory: two.path, - fn: () => grabInstance(Vcs.Service), - }) - - expect(vcsOne).not.toBe(vcsTwo) - }) - - test("disposal rebuilds services with a new instance", async () => { - await using tmp = await tmpdir({ git: true }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const before = await grabInstance(Question.Service) - - await runtime.runPromise(Instances.use((map) => map.invalidate(Instance.directory))) - - const after = await grabInstance(Question.Service) - expect(after).not.toBe(before) - }, - }) - }) -}) diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index 8f4cbe8688c..fae3ac1f286 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from "bun:test" +import { afterEach, describe, test, expect } from "bun:test" import { $ } from "bun" import path from "path" import fs from "fs/promises" @@ -7,6 +7,10 @@ import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" +afterEach(async () => { + await Instance.disposeAll() +}) + describe("file/index Filesystem patterns", () => { describe("File.read() - text content", () => { test("reads text file via Filesystem.readText()", async () => { @@ -689,6 +693,18 @@ describe("file/index Filesystem patterns", () => { }) }) + test("search works before explicit init", async () => { + await using tmp = await setupSearchableRepo() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.search({ query: "main", type: "file" }) + expect(result.some((f) => f.includes("main"))).toBe(true) + }, + }) + }) + test("empty query returns dirs sorted with hidden last", async () => { await using tmp = await setupSearchableRepo() @@ -785,6 +801,23 @@ describe("file/index Filesystem patterns", () => { }, }) }) + + test("search refreshes after init when files change", async () => { + await using tmp = await setupSearchableRepo() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await File.init() + expect(await File.search({ query: "fresh", type: "file" })).toEqual([]) + + await fs.writeFile(path.join(tmp.path, "fresh.ts"), "fresh", "utf-8") + + const result = await File.search({ query: "fresh", type: "file" }) + expect(result).toContain("fresh.ts") + }, + }) + }) }) describe("File.read() - diff/patch", () => { @@ -849,4 +882,65 @@ describe("file/index Filesystem patterns", () => { }) }) }) + + describe("InstanceState isolation", () => { + test("two directories get independent file caches", async () => { + await using one = await tmpdir({ git: true }) + await using two = await tmpdir({ git: true }) + await fs.writeFile(path.join(one.path, "a.ts"), "one", "utf-8") + await fs.writeFile(path.join(two.path, "b.ts"), "two", "utf-8") + + await Instance.provide({ + directory: one.path, + fn: async () => { + await File.init() + const results = await File.search({ query: "a.ts", type: "file" }) + expect(results).toContain("a.ts") + const results2 = await File.search({ query: "b.ts", type: "file" }) + expect(results2).not.toContain("b.ts") + }, + }) + + await Instance.provide({ + directory: two.path, + fn: async () => { + await File.init() + const results = await File.search({ query: "b.ts", type: "file" }) + expect(results).toContain("b.ts") + const results2 = await File.search({ query: "a.ts", type: "file" }) + expect(results2).not.toContain("a.ts") + }, + }) + }) + + test("disposal gives fresh state on next access", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "before.ts"), "before", "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await File.init() + const results = await File.search({ query: "before", type: "file" }) + expect(results).toContain("before.ts") + }, + }) + + await Instance.disposeAll() + + await fs.writeFile(path.join(tmp.path, "after.ts"), "after", "utf-8") + await fs.rm(path.join(tmp.path, "before.ts")) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await File.init() + const results = await File.search({ query: "after", type: "file" }) + expect(results).toContain("after.ts") + const stale = await File.search({ query: "before", type: "file" }) + expect(stale).not.toContain("before.ts") + }, + }) + }) + }) }) diff --git a/packages/opencode/test/file/time.test.ts b/packages/opencode/test/file/time.test.ts index fbf8d5cd1e2..db7eaaae0d8 100644 --- a/packages/opencode/test/file/time.test.ts +++ b/packages/opencode/test/file/time.test.ts @@ -7,7 +7,9 @@ import { SessionID } from "../../src/session/schema" import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" -afterEach(() => Instance.disposeAll()) +afterEach(async () => { + await Instance.disposeAll() +}) async function touch(file: string, time: number) { const date = new Date(time) @@ -84,6 +86,28 @@ describe("file/time", () => { }, }) }) + + test("isolates reads by directory", async () => { + await using one = await tmpdir() + await using two = await tmpdir() + await using shared = await tmpdir() + const filepath = path.join(shared.path, "file.txt") + await fs.writeFile(filepath, "content", "utf-8") + + await Instance.provide({ + directory: one.path, + fn: async () => { + await FileTime.read(sessionID, filepath) + }, + }) + + await Instance.provide({ + directory: two.path, + fn: async () => { + expect(await FileTime.get(sessionID, filepath)).toBeUndefined() + }, + }) + }) }) describe("assert()", () => { diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index 2cd27643e88..f4f0c1c7d65 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -25,7 +25,7 @@ function withWatcher(directory: string, body: Effect.Effect) { directory, FileWatcher.layer, async (rt) => { - await rt.runPromise(FileWatcher.Service.use(() => Effect.void)) + await rt.runPromise(FileWatcher.Service.use((s) => s.init())) await Effect.runPromise(ready(directory)) await Effect.runPromise(body) }, @@ -136,7 +136,9 @@ function ready(directory: string) { // --------------------------------------------------------------------------- describeWatcher("FileWatcher", () => { - afterEach(() => Instance.disposeAll()) + afterEach(async () => { + await Instance.disposeAll() + }) test("publishes root create, update, and delete events", async () => { await using tmp = await tmpdir({ git: true }) diff --git a/packages/opencode/test/format/format.test.ts b/packages/opencode/test/format/format.test.ts index 2718e125d08..1992dede621 100644 --- a/packages/opencode/test/format/format.test.ts +++ b/packages/opencode/test/format/format.test.ts @@ -2,11 +2,16 @@ import { Effect } from "effect" import { afterEach, describe, expect, test } from "bun:test" import { tmpdir } from "../fixture/fixture" import { withServices } from "../fixture/instance" +import { Bus } from "../../src/bus" +import { File } from "../../src/file" import { Format } from "../../src/format" +import * as Formatter from "../../src/format/formatter" import { Instance } from "../../src/project/instance" describe("Format", () => { - afterEach(() => Instance.disposeAll()) + afterEach(async () => { + await Instance.disposeAll() + }) test("status() returns built-in formatters when no config overrides", async () => { await using tmp = await tmpdir() @@ -62,4 +67,106 @@ describe("Format", () => { await rt.runPromise(Format.Service.use(() => Effect.void)) }) }) + + test("status() initializes formatter state per directory", async () => { + await using off = await tmpdir({ + config: { formatter: false }, + }) + await using on = await tmpdir() + + const a = await Instance.provide({ + directory: off.path, + fn: () => Format.status(), + }) + const b = await Instance.provide({ + directory: on.path, + fn: () => Format.status(), + }) + + expect(a).toEqual([]) + expect(b.length).toBeGreaterThan(0) + }) + + test("runs enabled checks for matching formatters in parallel", async () => { + await using tmp = await tmpdir() + + const file = `${tmp.path}/test.parallel` + await Bun.write(file, "x") + + const one = { + extensions: Formatter.gofmt.extensions, + enabled: Formatter.gofmt.enabled, + command: Formatter.gofmt.command, + } + const two = { + extensions: Formatter.mix.extensions, + enabled: Formatter.mix.enabled, + command: Formatter.mix.command, + } + + let active = 0 + let max = 0 + + Formatter.gofmt.extensions = [".parallel"] + Formatter.mix.extensions = [".parallel"] + Formatter.gofmt.command = ["sh", "-c", "true"] + Formatter.mix.command = ["sh", "-c", "true"] + Formatter.gofmt.enabled = async () => { + active++ + max = Math.max(max, active) + await Bun.sleep(20) + active-- + return true + } + Formatter.mix.enabled = async () => { + active++ + max = Math.max(max, active) + await Bun.sleep(20) + active-- + return true + } + + try { + await withServices(tmp.path, Format.layer, async (rt) => { + await rt.runPromise(Format.Service.use((s) => s.init())) + await Bus.publish(File.Event.Edited, { file }) + }) + } finally { + Formatter.gofmt.extensions = one.extensions + Formatter.gofmt.enabled = one.enabled + Formatter.gofmt.command = one.command + Formatter.mix.extensions = two.extensions + Formatter.mix.enabled = two.enabled + Formatter.mix.command = two.command + } + + expect(max).toBe(2) + }) + + test("runs matching formatters sequentially for the same file", async () => { + await using tmp = await tmpdir({ + config: { + formatter: { + first: { + command: ["sh", "-c", "sleep 0.05; v=$(cat \"$1\"); printf '%sA' \"$v\" > \"$1\"", "sh", "$FILE"], + extensions: [".seq"], + }, + second: { + command: ["sh", "-c", "v=$(cat \"$1\"); printf '%sB' \"$v\" > \"$1\"", "sh", "$FILE"], + extensions: [".seq"], + }, + }, + }, + }) + + const file = `${tmp.path}/test.seq` + await Bun.write(file, "x") + + await withServices(tmp.path, Format.layer, async (rt) => { + await rt.runPromise(Format.Service.use((s) => s.init())) + await Bus.publish(File.Event.Edited, { file }) + }) + + expect(await Bun.file(file).text()).toBe("xAB") + }) }) diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts index c78da6e6a58..3ca32bf414e 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -1,11 +1,15 @@ -import { describe, test, expect } from "bun:test" -import { PermissionNext } from "../src/permission" +import { afterEach, describe, test, expect } from "bun:test" +import { Permission } from "../src/permission" import { Config } from "../src/config/config" import { Instance } from "../src/project/instance" import { tmpdir } from "./fixture/fixture" -describe("PermissionNext.evaluate for permission.task", () => { - const createRuleset = (rules: Record): PermissionNext.Ruleset => +afterEach(async () => { + await Instance.disposeAll() +}) + +describe("Permission.evaluate for permission.task", () => { + const createRuleset = (rules: Record): Permission.Ruleset => Object.entries(rules).map(([pattern, action]) => ({ permission: "task", pattern, @@ -13,42 +17,42 @@ describe("PermissionNext.evaluate for permission.task", () => { })) test("returns ask when no match (default)", () => { - expect(PermissionNext.evaluate("task", "code-reviewer", []).action).toBe("ask") + expect(Permission.evaluate("task", "code-reviewer", []).action).toBe("ask") }) test("returns deny for explicit deny", () => { const ruleset = createRuleset({ "code-reviewer": "deny" }) - expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") }) test("returns allow for explicit allow", () => { const ruleset = createRuleset({ "code-reviewer": "allow" }) - expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("allow") + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("allow") }) test("returns ask for explicit ask", () => { const ruleset = createRuleset({ "code-reviewer": "ask" }) - expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("ask") + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask") }) test("matches wildcard patterns with deny", () => { const ruleset = createRuleset({ "orchestrator-*": "deny" }) - expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny") - expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny") - expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask") + expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny") + expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny") + expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask") }) test("matches wildcard patterns with allow", () => { const ruleset = createRuleset({ "orchestrator-*": "allow" }) - expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow") - expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("allow") + expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow") + expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("allow") }) test("matches wildcard patterns with ask", () => { const ruleset = createRuleset({ "orchestrator-*": "ask" }) - expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("ask") + expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("ask") const globalRuleset = createRuleset({ "*": "ask" }) - expect(PermissionNext.evaluate("task", "code-reviewer", globalRuleset).action).toBe("ask") + expect(Permission.evaluate("task", "code-reviewer", globalRuleset).action).toBe("ask") }) test("later rules take precedence (last match wins)", () => { @@ -56,22 +60,22 @@ describe("PermissionNext.evaluate for permission.task", () => { "orchestrator-*": "deny", "orchestrator-fast": "allow", }) - expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow") - expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny") + expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow") + expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny") }) test("matches global wildcard", () => { - expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "allow" })).action).toBe("allow") - expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "deny" })).action).toBe("deny") - expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "ask" })).action).toBe("ask") + expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "allow" })).action).toBe("allow") + expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "deny" })).action).toBe("deny") + expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "ask" })).action).toBe("ask") }) }) -describe("PermissionNext.disabled for task tool", () => { +describe("Permission.disabled for task tool", () => { // Note: The `disabled` function checks if a TOOL should be completely removed from the tool list. // It only disables a tool when there's a rule with `pattern: "*"` and `action: "deny"`. // It does NOT evaluate complex subagent patterns - those are handled at runtime by `evaluate`. - const createRuleset = (rules: Record): PermissionNext.Ruleset => + const createRuleset = (rules: Record): Permission.Ruleset => Object.entries(rules).map(([pattern, action]) => ({ permission: "task", pattern, @@ -85,7 +89,7 @@ describe("PermissionNext.disabled for task tool", () => { "orchestrator-*": "allow", "*": "deny", }) - const disabled = PermissionNext.disabled(["task", "bash", "read"], ruleset) + const disabled = Permission.disabled(["task", "bash", "read"], ruleset) // The task tool IS disabled because there's a pattern: "*" with action: "deny" expect(disabled.has("task")).toBe(true) }) @@ -95,14 +99,14 @@ describe("PermissionNext.disabled for task tool", () => { "orchestrator-*": "ask", "*": "deny", }) - const disabled = PermissionNext.disabled(["task"], ruleset) + const disabled = Permission.disabled(["task"], ruleset) // The task tool IS disabled because there's a pattern: "*" with action: "deny" expect(disabled.has("task")).toBe(true) }) test("task tool is disabled when global deny pattern exists", () => { const ruleset = createRuleset({ "*": "deny" }) - const disabled = PermissionNext.disabled(["task"], ruleset) + const disabled = Permission.disabled(["task"], ruleset) expect(disabled.has("task")).toBe(true) }) @@ -113,13 +117,13 @@ describe("PermissionNext.disabled for task tool", () => { "orchestrator-*": "deny", general: "deny", }) - const disabled = PermissionNext.disabled(["task"], ruleset) + const disabled = Permission.disabled(["task"], ruleset) // The task tool is NOT disabled because no rule has pattern: "*" with action: "deny" expect(disabled.has("task")).toBe(false) }) test("task tool is enabled when no task rules exist (default ask)", () => { - const disabled = PermissionNext.disabled(["task"], []) + const disabled = Permission.disabled(["task"], []) expect(disabled.has("task")).toBe(false) }) @@ -129,7 +133,7 @@ describe("PermissionNext.disabled for task tool", () => { "*": "deny", "orchestrator-coder": "allow", }) - const disabled = PermissionNext.disabled(["task"], ruleset) + const disabled = Permission.disabled(["task"], ruleset) // The disabled() function uses findLast and checks if the last matching rule // has pattern: "*" and action: "deny". In this case, the last rule matching // "task" permission has pattern "orchestrator-coder", not "*", so not disabled @@ -155,11 +159,11 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await Config.get() - const ruleset = PermissionNext.fromConfig(config.permission ?? {}) + const ruleset = Permission.fromConfig(config.permission ?? {}) // general and orchestrator-fast should be allowed, code-reviewer denied - expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow") - expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow") - expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") + expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") + expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow") + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") }, }) }) @@ -180,11 +184,11 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await Config.get() - const ruleset = PermissionNext.fromConfig(config.permission ?? {}) + const ruleset = Permission.fromConfig(config.permission ?? {}) // general and code-reviewer should be ask, orchestrator-* denied - expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask") - expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("ask") - expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny") + expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask") + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask") + expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny") }, }) }) @@ -205,11 +209,11 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await Config.get() - const ruleset = PermissionNext.fromConfig(config.permission ?? {}) - expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow") - expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") + const ruleset = Permission.fromConfig(config.permission ?? {}) + expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") // Unspecified agents default to "ask" - expect(PermissionNext.evaluate("task", "unknown-agent", ruleset).action).toBe("ask") + expect(Permission.evaluate("task", "unknown-agent", ruleset).action).toBe("ask") }, }) }) @@ -232,18 +236,18 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await Config.get() - const ruleset = PermissionNext.fromConfig(config.permission ?? {}) + const ruleset = Permission.fromConfig(config.permission ?? {}) // Verify task permissions - expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow") - expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") + expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") // Verify other tool permissions - expect(PermissionNext.evaluate("bash", "*", ruleset).action).toBe("allow") - expect(PermissionNext.evaluate("edit", "*", ruleset).action).toBe("ask") + expect(Permission.evaluate("bash", "*", ruleset).action).toBe("allow") + expect(Permission.evaluate("edit", "*", ruleset).action).toBe("ask") // Verify disabled tools - const disabled = PermissionNext.disabled(["bash", "edit", "task"], ruleset) + const disabled = Permission.disabled(["bash", "edit", "task"], ruleset) expect(disabled.has("bash")).toBe(false) expect(disabled.has("edit")).toBe(false) // task is NOT disabled because disabled() uses findLast, and the last rule @@ -270,16 +274,16 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await Config.get() - const ruleset = PermissionNext.fromConfig(config.permission ?? {}) + const ruleset = Permission.fromConfig(config.permission ?? {}) // Last matching rule wins - "*" deny is last, so all agents are denied - expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("deny") - expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") - expect(PermissionNext.evaluate("task", "unknown", ruleset).action).toBe("deny") + expect(Permission.evaluate("task", "general", ruleset).action).toBe("deny") + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") + expect(Permission.evaluate("task", "unknown", ruleset).action).toBe("deny") // Since "*": "deny" is the last rule, disabled() finds it with findLast // and sees pattern: "*" with action: "deny", so task is disabled - const disabled = PermissionNext.disabled(["task"], ruleset) + const disabled = Permission.disabled(["task"], ruleset) expect(disabled.has("task")).toBe(true) }, }) @@ -301,17 +305,17 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await Config.get() - const ruleset = PermissionNext.fromConfig(config.permission ?? {}) + const ruleset = Permission.fromConfig(config.permission ?? {}) // Evaluate uses findLast - "general" allow comes after "*" deny - expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow") + expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") // Other agents still denied by the earlier "*" deny - expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") // disabled() uses findLast and checks if the last rule has pattern: "*" with action: "deny" // In this case, the last rule is {pattern: "general", action: "allow"}, not pattern: "*" // So the task tool is NOT disabled (even though most subagents are denied) - const disabled = PermissionNext.disabled(["task"], ruleset) + const disabled = Permission.disabled(["task"], ruleset) expect(disabled.has("task")).toBe(false) }, }) diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 2a6b6e0bafa..043e3257b64 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -1,11 +1,7 @@ import { afterEach, test, expect } from "bun:test" import os from "os" -import { Effect } from "effect" import { Bus } from "../../src/bus" -import { runtime } from "../../src/effect/runtime" -import { Instances } from "../../src/effect/instances" -import { PermissionNext } from "../../src/permission" -import { PermissionNext as S } from "../../src/permission" +import { Permission } from "../../src/permission" import { PermissionID } from "../../src/permission/schema" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" @@ -16,8 +12,8 @@ afterEach(async () => { }) async function rejectAll(message?: string) { - for (const req of await PermissionNext.list()) { - await PermissionNext.reply({ + for (const req of await Permission.list()) { + await Permission.reply({ requestID: req.id, reply: "reject", message, @@ -27,22 +23,22 @@ async function rejectAll(message?: string) { async function waitForPending(count: number) { for (let i = 0; i < 20; i++) { - const list = await PermissionNext.list() + const list = await Permission.list() if (list.length === count) return list await Bun.sleep(0) } - return PermissionNext.list() + return Permission.list() } // fromConfig tests test("fromConfig - string value becomes wildcard rule", () => { - const result = PermissionNext.fromConfig({ bash: "allow" }) + const result = Permission.fromConfig({ bash: "allow" }) expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }]) }) test("fromConfig - object value converts to rules array", () => { - const result = PermissionNext.fromConfig({ bash: { "*": "allow", rm: "deny" } }) + const result = Permission.fromConfig({ bash: { "*": "allow", rm: "deny" } }) expect(result).toEqual([ { permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "rm", action: "deny" }, @@ -50,7 +46,7 @@ test("fromConfig - object value converts to rules array", () => { }) test("fromConfig - mixed string and object values", () => { - const result = PermissionNext.fromConfig({ + const result = Permission.fromConfig({ bash: { "*": "allow", rm: "deny" }, edit: "allow", webfetch: "ask", @@ -64,51 +60,51 @@ test("fromConfig - mixed string and object values", () => { }) test("fromConfig - empty object", () => { - const result = PermissionNext.fromConfig({}) + const result = Permission.fromConfig({}) expect(result).toEqual([]) }) test("fromConfig - expands tilde to home directory", () => { - const result = PermissionNext.fromConfig({ external_directory: { "~/projects/*": "allow" } }) + const result = Permission.fromConfig({ external_directory: { "~/projects/*": "allow" } }) expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }]) }) test("fromConfig - expands $HOME to home directory", () => { - const result = PermissionNext.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } }) + const result = Permission.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } }) expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }]) }) test("fromConfig - expands $HOME without trailing slash", () => { - const result = PermissionNext.fromConfig({ external_directory: { $HOME: "allow" } }) + const result = Permission.fromConfig({ external_directory: { $HOME: "allow" } }) expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }]) }) test("fromConfig - does not expand tilde in middle of path", () => { - const result = PermissionNext.fromConfig({ external_directory: { "/some/~/path": "allow" } }) + const result = Permission.fromConfig({ external_directory: { "/some/~/path": "allow" } }) expect(result).toEqual([{ permission: "external_directory", pattern: "/some/~/path", action: "allow" }]) }) test("fromConfig - expands exact tilde to home directory", () => { - const result = PermissionNext.fromConfig({ external_directory: { "~": "allow" } }) + const result = Permission.fromConfig({ external_directory: { "~": "allow" } }) expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }]) }) test("evaluate - matches expanded tilde pattern", () => { - const ruleset = PermissionNext.fromConfig({ external_directory: { "~/projects/*": "allow" } }) - const result = PermissionNext.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset) + const ruleset = Permission.fromConfig({ external_directory: { "~/projects/*": "allow" } }) + const result = Permission.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset) expect(result.action).toBe("allow") }) test("evaluate - matches expanded $HOME pattern", () => { - const ruleset = PermissionNext.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } }) - const result = PermissionNext.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset) + const ruleset = Permission.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } }) + const result = Permission.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset) expect(result.action).toBe("allow") }) // merge tests test("merge - simple concatenation", () => { - const result = PermissionNext.merge( + const result = Permission.merge( [{ permission: "bash", pattern: "*", action: "allow" }], [{ permission: "bash", pattern: "*", action: "deny" }], ) @@ -119,7 +115,7 @@ test("merge - simple concatenation", () => { }) test("merge - adds new permission", () => { - const result = PermissionNext.merge( + const result = Permission.merge( [{ permission: "bash", pattern: "*", action: "allow" }], [{ permission: "edit", pattern: "*", action: "deny" }], ) @@ -130,7 +126,7 @@ test("merge - adds new permission", () => { }) test("merge - concatenates rules for same permission", () => { - const result = PermissionNext.merge( + const result = Permission.merge( [{ permission: "bash", pattern: "foo", action: "ask" }], [{ permission: "bash", pattern: "*", action: "deny" }], ) @@ -141,7 +137,7 @@ test("merge - concatenates rules for same permission", () => { }) test("merge - multiple rulesets", () => { - const result = PermissionNext.merge( + const result = Permission.merge( [{ permission: "bash", pattern: "*", action: "allow" }], [{ permission: "bash", pattern: "rm", action: "ask" }], [{ permission: "edit", pattern: "*", action: "allow" }], @@ -154,12 +150,12 @@ test("merge - multiple rulesets", () => { }) test("merge - empty ruleset does nothing", () => { - const result = PermissionNext.merge([{ permission: "bash", pattern: "*", action: "allow" }], []) + const result = Permission.merge([{ permission: "bash", pattern: "*", action: "allow" }], []) expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }]) }) test("merge - preserves rule order", () => { - const result = PermissionNext.merge( + const result = Permission.merge( [ { permission: "edit", pattern: "src/*", action: "allow" }, { permission: "edit", pattern: "src/secret/*", action: "deny" }, @@ -175,40 +171,40 @@ test("merge - preserves rule order", () => { test("merge - config permission overrides default ask", () => { // Simulates: defaults have "*": "ask", config sets bash: "allow" - const defaults: PermissionNext.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }] - const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] - const merged = PermissionNext.merge(defaults, config) + const defaults: Permission.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }] + const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] + const merged = Permission.merge(defaults, config) // Config's bash allow should override default ask - expect(PermissionNext.evaluate("bash", "ls", merged).action).toBe("allow") + expect(Permission.evaluate("bash", "ls", merged).action).toBe("allow") // Other permissions should still be ask (from defaults) - expect(PermissionNext.evaluate("edit", "foo.ts", merged).action).toBe("ask") + expect(Permission.evaluate("edit", "foo.ts", merged).action).toBe("ask") }) test("merge - config ask overrides default allow", () => { // Simulates: defaults have bash: "allow", config sets bash: "ask" - const defaults: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] - const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }] - const merged = PermissionNext.merge(defaults, config) + const defaults: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] + const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }] + const merged = Permission.merge(defaults, config) // Config's ask should override default allow - expect(PermissionNext.evaluate("bash", "ls", merged).action).toBe("ask") + expect(Permission.evaluate("bash", "ls", merged).action).toBe("ask") }) // evaluate tests test("evaluate - exact pattern match", () => { - const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }]) + const result = Permission.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }]) expect(result.action).toBe("deny") }) test("evaluate - wildcard pattern match", () => { - const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }]) + const result = Permission.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }]) expect(result.action).toBe("allow") }) test("evaluate - last matching rule wins", () => { - const result = PermissionNext.evaluate("bash", "rm", [ + const result = Permission.evaluate("bash", "rm", [ { permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "rm", action: "deny" }, ]) @@ -216,7 +212,7 @@ test("evaluate - last matching rule wins", () => { }) test("evaluate - last matching rule wins (wildcard after specific)", () => { - const result = PermissionNext.evaluate("bash", "rm", [ + const result = Permission.evaluate("bash", "rm", [ { permission: "bash", pattern: "rm", action: "deny" }, { permission: "bash", pattern: "*", action: "allow" }, ]) @@ -224,14 +220,12 @@ test("evaluate - last matching rule wins (wildcard after specific)", () => { }) test("evaluate - glob pattern match", () => { - const result = PermissionNext.evaluate("edit", "src/foo.ts", [ - { permission: "edit", pattern: "src/*", action: "allow" }, - ]) + const result = Permission.evaluate("edit", "src/foo.ts", [{ permission: "edit", pattern: "src/*", action: "allow" }]) expect(result.action).toBe("allow") }) test("evaluate - last matching glob wins", () => { - const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [ + const result = Permission.evaluate("edit", "src/components/Button.tsx", [ { permission: "edit", pattern: "src/*", action: "deny" }, { permission: "edit", pattern: "src/components/*", action: "allow" }, ]) @@ -240,7 +234,7 @@ test("evaluate - last matching glob wins", () => { test("evaluate - order matters for specificity", () => { // If more specific rule comes first, later wildcard overrides it - const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [ + const result = Permission.evaluate("edit", "src/components/Button.tsx", [ { permission: "edit", pattern: "src/components/*", action: "allow" }, { permission: "edit", pattern: "src/*", action: "deny" }, ]) @@ -248,31 +242,29 @@ test("evaluate - order matters for specificity", () => { }) test("evaluate - unknown permission returns ask", () => { - const result = PermissionNext.evaluate("unknown_tool", "anything", [ + const result = Permission.evaluate("unknown_tool", "anything", [ { permission: "bash", pattern: "*", action: "allow" }, ]) expect(result.action).toBe("ask") }) test("evaluate - empty ruleset returns ask", () => { - const result = PermissionNext.evaluate("bash", "rm", []) + const result = Permission.evaluate("bash", "rm", []) expect(result.action).toBe("ask") }) test("evaluate - no matching pattern returns ask", () => { - const result = PermissionNext.evaluate("edit", "etc/passwd", [ - { permission: "edit", pattern: "src/*", action: "allow" }, - ]) + const result = Permission.evaluate("edit", "etc/passwd", [{ permission: "edit", pattern: "src/*", action: "allow" }]) expect(result.action).toBe("ask") }) test("evaluate - empty rules array returns ask", () => { - const result = PermissionNext.evaluate("bash", "rm", []) + const result = Permission.evaluate("bash", "rm", []) expect(result.action).toBe("ask") }) test("evaluate - multiple matching patterns, last wins", () => { - const result = PermissionNext.evaluate("edit", "src/secret.ts", [ + const result = Permission.evaluate("edit", "src/secret.ts", [ { permission: "edit", pattern: "*", action: "ask" }, { permission: "edit", pattern: "src/*", action: "allow" }, { permission: "edit", pattern: "src/secret.ts", action: "deny" }, @@ -281,7 +273,7 @@ test("evaluate - multiple matching patterns, last wins", () => { }) test("evaluate - non-matching patterns are skipped", () => { - const result = PermissionNext.evaluate("edit", "src/foo.ts", [ + const result = Permission.evaluate("edit", "src/foo.ts", [ { permission: "edit", pattern: "*", action: "ask" }, { permission: "edit", pattern: "test/*", action: "deny" }, { permission: "edit", pattern: "src/*", action: "allow" }, @@ -290,7 +282,7 @@ test("evaluate - non-matching patterns are skipped", () => { }) test("evaluate - exact match at end wins over earlier wildcard", () => { - const result = PermissionNext.evaluate("bash", "/bin/rm", [ + const result = Permission.evaluate("bash", "/bin/rm", [ { permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "/bin/rm", action: "deny" }, ]) @@ -298,7 +290,7 @@ test("evaluate - exact match at end wins over earlier wildcard", () => { }) test("evaluate - wildcard at end overrides earlier exact match", () => { - const result = PermissionNext.evaluate("bash", "/bin/rm", [ + const result = Permission.evaluate("bash", "/bin/rm", [ { permission: "bash", pattern: "/bin/rm", action: "deny" }, { permission: "bash", pattern: "*", action: "allow" }, ]) @@ -308,24 +300,24 @@ test("evaluate - wildcard at end overrides earlier exact match", () => { // wildcard permission tests test("evaluate - wildcard permission matches any permission", () => { - const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }]) + const result = Permission.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }]) expect(result.action).toBe("deny") }) test("evaluate - wildcard permission with specific pattern", () => { - const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }]) + const result = Permission.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }]) expect(result.action).toBe("deny") }) test("evaluate - glob permission pattern", () => { - const result = PermissionNext.evaluate("mcp_server_tool", "anything", [ + const result = Permission.evaluate("mcp_server_tool", "anything", [ { permission: "mcp_*", pattern: "*", action: "allow" }, ]) expect(result.action).toBe("allow") }) test("evaluate - specific permission and wildcard permission combined", () => { - const result = PermissionNext.evaluate("bash", "rm", [ + const result = Permission.evaluate("bash", "rm", [ { permission: "*", pattern: "*", action: "deny" }, { permission: "bash", pattern: "*", action: "allow" }, ]) @@ -333,7 +325,7 @@ test("evaluate - specific permission and wildcard permission combined", () => { }) test("evaluate - wildcard permission does not match when specific exists", () => { - const result = PermissionNext.evaluate("edit", "src/foo.ts", [ + const result = Permission.evaluate("edit", "src/foo.ts", [ { permission: "*", pattern: "*", action: "deny" }, { permission: "edit", pattern: "src/*", action: "allow" }, ]) @@ -341,7 +333,7 @@ test("evaluate - wildcard permission does not match when specific exists", () => }) test("evaluate - multiple matching permission patterns combine rules", () => { - const result = PermissionNext.evaluate("mcp_dangerous", "anything", [ + const result = Permission.evaluate("mcp_dangerous", "anything", [ { permission: "*", pattern: "*", action: "ask" }, { permission: "mcp_*", pattern: "*", action: "allow" }, { permission: "mcp_dangerous", pattern: "*", action: "deny" }, @@ -350,7 +342,7 @@ test("evaluate - multiple matching permission patterns combine rules", () => { }) test("evaluate - wildcard permission fallback for unknown tool", () => { - const result = PermissionNext.evaluate("unknown_tool", "anything", [ + const result = Permission.evaluate("unknown_tool", "anything", [ { permission: "*", pattern: "*", action: "ask" }, { permission: "bash", pattern: "*", action: "allow" }, ]) @@ -359,7 +351,7 @@ test("evaluate - wildcard permission fallback for unknown tool", () => { test("evaluate - permission patterns sorted by length regardless of object order", () => { // specific permission listed before wildcard, but specific should still win - const result = PermissionNext.evaluate("bash", "rm", [ + const result = Permission.evaluate("bash", "rm", [ { permission: "bash", pattern: "*", action: "allow" }, { permission: "*", pattern: "*", action: "deny" }, ]) @@ -368,22 +360,22 @@ test("evaluate - permission patterns sorted by length regardless of object order }) test("evaluate - merges multiple rulesets", () => { - const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] - const approved: PermissionNext.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }] + const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] + const approved: Permission.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }] // approved comes after config, so rm should be denied - const result = PermissionNext.evaluate("bash", "rm", config, approved) + const result = Permission.evaluate("bash", "rm", config, approved) expect(result.action).toBe("deny") }) // disabled tests test("disabled - returns empty set when all tools allowed", () => { - const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }]) + const result = Permission.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }]) expect(result.size).toBe(0) }) test("disabled - disables tool when denied", () => { - const result = PermissionNext.disabled( + const result = Permission.disabled( ["bash", "edit", "read"], [ { permission: "*", pattern: "*", action: "allow" }, @@ -396,7 +388,7 @@ test("disabled - disables tool when denied", () => { }) test("disabled - disables edit/write/apply_patch/multiedit when edit denied", () => { - const result = PermissionNext.disabled( + const result = Permission.disabled( ["edit", "write", "apply_patch", "multiedit", "bash"], [ { permission: "*", pattern: "*", action: "allow" }, @@ -411,7 +403,7 @@ test("disabled - disables edit/write/apply_patch/multiedit when edit denied", () }) test("disabled - does not disable when partially denied", () => { - const result = PermissionNext.disabled( + const result = Permission.disabled( ["bash"], [ { permission: "bash", pattern: "*", action: "allow" }, @@ -422,14 +414,14 @@ test("disabled - does not disable when partially denied", () => { }) test("disabled - does not disable when action is ask", () => { - const result = PermissionNext.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }]) + const result = Permission.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }]) expect(result.size).toBe(0) }) test("disabled - does not disable when specific allow after wildcard deny", () => { // Tool is NOT disabled because a specific allow after wildcard deny means // there's at least some usage allowed - const result = PermissionNext.disabled( + const result = Permission.disabled( ["bash"], [ { permission: "bash", pattern: "*", action: "deny" }, @@ -440,7 +432,7 @@ test("disabled - does not disable when specific allow after wildcard deny", () = }) test("disabled - does not disable when wildcard allow after deny", () => { - const result = PermissionNext.disabled( + const result = Permission.disabled( ["bash"], [ { permission: "bash", pattern: "rm *", action: "deny" }, @@ -451,7 +443,7 @@ test("disabled - does not disable when wildcard allow after deny", () => { }) test("disabled - disables multiple tools", () => { - const result = PermissionNext.disabled( + const result = Permission.disabled( ["bash", "edit", "webfetch"], [ { permission: "bash", pattern: "*", action: "deny" }, @@ -465,14 +457,14 @@ test("disabled - disables multiple tools", () => { }) test("disabled - wildcard permission denies all tools", () => { - const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }]) + const result = Permission.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }]) expect(result.has("bash")).toBe(true) expect(result.has("edit")).toBe(true) expect(result.has("read")).toBe(true) }) test("disabled - specific allow overrides wildcard deny", () => { - const result = PermissionNext.disabled( + const result = Permission.disabled( ["bash", "edit", "read"], [ { permission: "*", pattern: "*", action: "deny" }, @@ -491,7 +483,7 @@ test("ask - resolves immediately when action is allow", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await PermissionNext.ask({ + const result = await Permission.ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["ls"], @@ -510,7 +502,7 @@ test("ask - throws RejectedError when action is deny", async () => { directory: tmp.path, fn: async () => { await expect( - PermissionNext.ask({ + Permission.ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["rm -rf /"], @@ -518,7 +510,7 @@ test("ask - throws RejectedError when action is deny", async () => { always: [], ruleset: [{ permission: "bash", pattern: "*", action: "deny" }], }), - ).rejects.toBeInstanceOf(PermissionNext.DeniedError) + ).rejects.toBeInstanceOf(Permission.DeniedError) }, }) }) @@ -528,7 +520,7 @@ test("ask - returns pending promise when action is ask", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const promise = PermissionNext.ask({ + const promise = Permission.ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["ls"], @@ -550,7 +542,7 @@ test("ask - adds request to pending list", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const ask = PermissionNext.ask({ + const ask = Permission.ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["ls"], @@ -563,7 +555,7 @@ test("ask - adds request to pending list", async () => { ruleset: [], }) - const list = await PermissionNext.list() + const list = await Permission.list() expect(list).toHaveLength(1) expect(list[0]).toMatchObject({ sessionID: SessionID.make("session_test"), @@ -588,12 +580,12 @@ test("ask - publishes asked event", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - let seen: PermissionNext.Request | undefined - const unsub = Bus.subscribe(PermissionNext.Event.Asked, (event) => { + let seen: Permission.Request | undefined + const unsub = Bus.subscribe(Permission.Event.Asked, (event) => { seen = event.properties }) - const ask = PermissionNext.ask({ + const ask = Permission.ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["ls"], @@ -606,7 +598,7 @@ test("ask - publishes asked event", async () => { ruleset: [], }) - expect(await PermissionNext.list()).toHaveLength(1) + expect(await Permission.list()).toHaveLength(1) expect(seen).toBeDefined() expect(seen).toMatchObject({ sessionID: SessionID.make("session_test"), @@ -628,7 +620,7 @@ test("reply - once resolves the pending ask", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const askPromise = PermissionNext.ask({ + const askPromise = Permission.ask({ id: PermissionID.make("per_test1"), sessionID: SessionID.make("session_test"), permission: "bash", @@ -640,7 +632,7 @@ test("reply - once resolves the pending ask", async () => { await waitForPending(1) - await PermissionNext.reply({ + await Permission.reply({ requestID: PermissionID.make("per_test1"), reply: "once", }) @@ -655,7 +647,7 @@ test("reply - reject throws RejectedError", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const askPromise = PermissionNext.ask({ + const askPromise = Permission.ask({ id: PermissionID.make("per_test2"), sessionID: SessionID.make("session_test"), permission: "bash", @@ -667,12 +659,12 @@ test("reply - reject throws RejectedError", async () => { await waitForPending(1) - await PermissionNext.reply({ + await Permission.reply({ requestID: PermissionID.make("per_test2"), reply: "reject", }) - await expect(askPromise).rejects.toBeInstanceOf(PermissionNext.RejectedError) + await expect(askPromise).rejects.toBeInstanceOf(Permission.RejectedError) }, }) }) @@ -682,7 +674,7 @@ test("reply - reject with message throws CorrectedError", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const ask = PermissionNext.ask({ + const ask = Permission.ask({ id: PermissionID.make("per_test2b"), sessionID: SessionID.make("session_test"), permission: "bash", @@ -694,14 +686,14 @@ test("reply - reject with message throws CorrectedError", async () => { await waitForPending(1) - await PermissionNext.reply({ + await Permission.reply({ requestID: PermissionID.make("per_test2b"), reply: "reject", message: "Use a safer command", }) const err = await ask.catch((err) => err) - expect(err).toBeInstanceOf(PermissionNext.CorrectedError) + expect(err).toBeInstanceOf(Permission.CorrectedError) expect(err.message).toContain("Use a safer command") }, }) @@ -712,7 +704,7 @@ test("reply - always persists approval and resolves", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const askPromise = PermissionNext.ask({ + const askPromise = Permission.ask({ id: PermissionID.make("per_test3"), sessionID: SessionID.make("session_test"), permission: "bash", @@ -724,7 +716,7 @@ test("reply - always persists approval and resolves", async () => { await waitForPending(1) - await PermissionNext.reply({ + await Permission.reply({ requestID: PermissionID.make("per_test3"), reply: "always", }) @@ -737,7 +729,7 @@ test("reply - always persists approval and resolves", async () => { directory: tmp.path, fn: async () => { // Stored approval should allow without asking - const result = await PermissionNext.ask({ + const result = await Permission.ask({ sessionID: SessionID.make("session_test2"), permission: "bash", patterns: ["ls"], @@ -755,7 +747,7 @@ test("reply - reject cancels all pending for same session", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const askPromise1 = PermissionNext.ask({ + const askPromise1 = Permission.ask({ id: PermissionID.make("per_test4a"), sessionID: SessionID.make("session_same"), permission: "bash", @@ -765,7 +757,7 @@ test("reply - reject cancels all pending for same session", async () => { ruleset: [], }) - const askPromise2 = PermissionNext.ask({ + const askPromise2 = Permission.ask({ id: PermissionID.make("per_test4b"), sessionID: SessionID.make("session_same"), permission: "edit", @@ -782,14 +774,14 @@ test("reply - reject cancels all pending for same session", async () => { const result2 = askPromise2.catch((e) => e) // Reject the first one - await PermissionNext.reply({ + await Permission.reply({ requestID: PermissionID.make("per_test4a"), reply: "reject", }) // Both should be rejected - expect(await result1).toBeInstanceOf(PermissionNext.RejectedError) - expect(await result2).toBeInstanceOf(PermissionNext.RejectedError) + expect(await result1).toBeInstanceOf(Permission.RejectedError) + expect(await result2).toBeInstanceOf(Permission.RejectedError) }, }) }) @@ -799,7 +791,7 @@ test("reply - always resolves matching pending requests in same session", async await Instance.provide({ directory: tmp.path, fn: async () => { - const a = PermissionNext.ask({ + const a = Permission.ask({ id: PermissionID.make("per_test5a"), sessionID: SessionID.make("session_same"), permission: "bash", @@ -809,7 +801,7 @@ test("reply - always resolves matching pending requests in same session", async ruleset: [], }) - const b = PermissionNext.ask({ + const b = Permission.ask({ id: PermissionID.make("per_test5b"), sessionID: SessionID.make("session_same"), permission: "bash", @@ -821,14 +813,14 @@ test("reply - always resolves matching pending requests in same session", async await waitForPending(2) - await PermissionNext.reply({ + await Permission.reply({ requestID: PermissionID.make("per_test5a"), reply: "always", }) await expect(a).resolves.toBeUndefined() await expect(b).resolves.toBeUndefined() - expect(await PermissionNext.list()).toHaveLength(0) + expect(await Permission.list()).toHaveLength(0) }, }) }) @@ -838,7 +830,7 @@ test("reply - always keeps other session pending", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const a = PermissionNext.ask({ + const a = Permission.ask({ id: PermissionID.make("per_test6a"), sessionID: SessionID.make("session_a"), permission: "bash", @@ -848,7 +840,7 @@ test("reply - always keeps other session pending", async () => { ruleset: [], }) - const b = PermissionNext.ask({ + const b = Permission.ask({ id: PermissionID.make("per_test6b"), sessionID: SessionID.make("session_b"), permission: "bash", @@ -860,13 +852,13 @@ test("reply - always keeps other session pending", async () => { await waitForPending(2) - await PermissionNext.reply({ + await Permission.reply({ requestID: PermissionID.make("per_test6a"), reply: "always", }) await expect(a).resolves.toBeUndefined() - expect((await PermissionNext.list()).map((x) => x.id)).toEqual([PermissionID.make("per_test6b")]) + expect((await Permission.list()).map((x) => x.id)).toEqual([PermissionID.make("per_test6b")]) await rejectAll() await b.catch(() => {}) @@ -879,7 +871,7 @@ test("reply - publishes replied event", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const ask = PermissionNext.ask({ + const ask = Permission.ask({ id: PermissionID.make("per_test7"), sessionID: SessionID.make("session_test"), permission: "bash", @@ -895,14 +887,14 @@ test("reply - publishes replied event", async () => { | { sessionID: SessionID requestID: PermissionID - reply: PermissionNext.Reply + reply: Permission.Reply } | undefined - const unsub = Bus.subscribe(PermissionNext.Event.Replied, (event) => { + const unsub = Bus.subscribe(Permission.Event.Replied, (event) => { seen = event.properties }) - await PermissionNext.reply({ + await Permission.reply({ requestID: PermissionID.make("per_test7"), reply: "once", }) @@ -918,16 +910,141 @@ test("reply - publishes replied event", async () => { }) }) +test("permission requests stay isolated by directory", async () => { + await using one = await tmpdir({ git: true }) + await using two = await tmpdir({ git: true }) + + const a = Instance.provide({ + directory: one.path, + fn: () => + Permission.ask({ + id: PermissionID.make("per_dir_a"), + sessionID: SessionID.make("session_dir_a"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }), + }) + + const b = Instance.provide({ + directory: two.path, + fn: () => + Permission.ask({ + id: PermissionID.make("per_dir_b"), + sessionID: SessionID.make("session_dir_b"), + permission: "bash", + patterns: ["pwd"], + metadata: {}, + always: [], + ruleset: [], + }), + }) + + const onePending = await Instance.provide({ + directory: one.path, + fn: () => waitForPending(1), + }) + const twoPending = await Instance.provide({ + directory: two.path, + fn: () => waitForPending(1), + }) + + expect(onePending).toHaveLength(1) + expect(twoPending).toHaveLength(1) + expect(onePending[0].id).toBe(PermissionID.make("per_dir_a")) + expect(twoPending[0].id).toBe(PermissionID.make("per_dir_b")) + + await Instance.provide({ + directory: one.path, + fn: () => Permission.reply({ requestID: onePending[0].id, reply: "reject" }), + }) + await Instance.provide({ + directory: two.path, + fn: () => Permission.reply({ requestID: twoPending[0].id, reply: "reject" }), + }) + + await a.catch(() => {}) + await b.catch(() => {}) +}) + +test("pending permission rejects on instance dispose", async () => { + await using tmp = await tmpdir({ git: true }) + + const ask = Instance.provide({ + directory: tmp.path, + fn: () => + Permission.ask({ + id: PermissionID.make("per_dispose"), + sessionID: SessionID.make("session_dispose"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }), + }) + const result = ask.then( + () => "resolved" as const, + (err) => err, + ) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const pending = await waitForPending(1) + expect(pending).toHaveLength(1) + await Instance.dispose() + }, + }) + + expect(await result).toBeInstanceOf(Permission.RejectedError) +}) + +test("pending permission rejects on instance reload", async () => { + await using tmp = await tmpdir({ git: true }) + + const ask = Instance.provide({ + directory: tmp.path, + fn: () => + Permission.ask({ + id: PermissionID.make("per_reload"), + sessionID: SessionID.make("session_reload"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }), + }) + const result = ask.then( + () => "resolved" as const, + (err) => err, + ) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const pending = await waitForPending(1) + expect(pending).toHaveLength(1) + await Instance.reload({ directory: tmp.path }) + }, + }) + + expect(await result).toBeInstanceOf(Permission.RejectedError) +}) + test("reply - does nothing for unknown requestID", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { - await PermissionNext.reply({ + await Permission.reply({ requestID: PermissionID.make("per_unknown"), reply: "once", }) - expect(await PermissionNext.list()).toHaveLength(0) + expect(await Permission.list()).toHaveLength(0) }, }) }) @@ -938,7 +1055,7 @@ test("ask - checks all patterns and stops on first deny", async () => { directory: tmp.path, fn: async () => { await expect( - PermissionNext.ask({ + Permission.ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["echo hello", "rm -rf /"], @@ -949,7 +1066,7 @@ test("ask - checks all patterns and stops on first deny", async () => { { permission: "bash", pattern: "rm *", action: "deny" }, ], }), - ).rejects.toBeInstanceOf(PermissionNext.DeniedError) + ).rejects.toBeInstanceOf(Permission.DeniedError) }, }) }) @@ -959,7 +1076,7 @@ test("ask - allows all patterns when all match allow rules", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await PermissionNext.ask({ + const result = await Permission.ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["echo hello", "ls -la", "pwd"], @@ -977,7 +1094,7 @@ test("ask - should deny even when an earlier pattern is ask", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const err = await PermissionNext.ask({ + const err = await Permission.ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["echo hello", "rm -rf /"], @@ -992,8 +1109,8 @@ test("ask - should deny even when an earlier pattern is ask", async () => { (err) => err, ) - expect(err).toBeInstanceOf(PermissionNext.DeniedError) - expect(await PermissionNext.list()).toHaveLength(0) + expect(err).toBeInstanceOf(Permission.DeniedError) + expect(await Permission.list()).toHaveLength(0) }, }) }) @@ -1004,8 +1121,8 @@ test("ask - abort should clear pending request", async () => { directory: tmp.path, fn: async () => { const ctl = new AbortController() - const ask = runtime.runPromise( - S.Service.use((svc) => + const ask = Permission.runPromise( + (svc) => svc.ask({ sessionID: SessionID.make("session_test"), permission: "bash", @@ -1014,7 +1131,6 @@ test("ask - abort should clear pending request", async () => { always: [], ruleset: [{ permission: "bash", pattern: "*", action: "ask" }], }), - ).pipe(Effect.provide(Instances.get(Instance.directory))), { signal: ctl.signal }, ) @@ -1023,7 +1139,7 @@ test("ask - abort should clear pending request", async () => { await ask.catch(() => {}) try { - expect(await PermissionNext.list()).toHaveLength(0) + expect(await Permission.list()).toHaveLength(0) } finally { await rejectAll() } diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index 0095ff38753..b967262254e 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -31,15 +31,26 @@ describe("plugin.auth-override", () => { }, }) - await Instance.provide({ + await using plain = await tmpdir() + + const methods = await Instance.provide({ directory: tmp.path, fn: async () => { - const methods = await ProviderAuth.methods() - const copilot = methods[ProviderID.make("github-copilot")] - expect(copilot).toBeDefined() - expect(copilot.length).toBe(1) - expect(copilot[0].label).toBe("Test Override Auth") + return ProviderAuth.methods() + }, + }) + + const plainMethods = await Instance.provide({ + directory: plain.path, + fn: async () => { + return ProviderAuth.methods() }, }) + + const copilot = methods[ProviderID.make("github-copilot")] + expect(copilot).toBeDefined() + expect(copilot.length).toBe(1) + expect(copilot[0].label).toBe("Test Override Auth") + expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth") }, 30000) // Increased timeout for plugin installation }) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 90f445ed782..11463b79502 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -25,8 +25,8 @@ function withVcs( directory, Layer.merge(FileWatcher.layer, Vcs.layer), async (rt) => { - await rt.runPromise(FileWatcher.Service.use(() => Effect.void)) - await rt.runPromise(Vcs.Service.use(() => Effect.void)) + await rt.runPromise(FileWatcher.Service.use((s) => s.init())) + await rt.runPromise(Vcs.Service.use((s) => s.init())) await Bun.sleep(500) await body(rt) }, @@ -67,7 +67,9 @@ function nextBranchUpdate(directory: string, timeout = 10_000) { // --------------------------------------------------------------------------- describeVcs("Vcs", () => { - afterEach(() => Instance.disposeAll()) + afterEach(async () => { + await Instance.disposeAll() + }) test("branch() returns current branch name", async () => { await using tmp = await tmpdir({ git: true }) diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index 45e0d3c318c..adfeda395a9 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -320,3 +320,134 @@ test("list - returns empty when no pending", async () => { }, }) }) + +test("questions stay isolated by directory", async () => { + await using one = await tmpdir({ git: true }) + await using two = await tmpdir({ git: true }) + + const p1 = Instance.provide({ + directory: one.path, + fn: () => + Question.ask({ + sessionID: SessionID.make("ses_one"), + questions: [ + { + question: "Question 1?", + header: "Q1", + options: [{ label: "A", description: "A" }], + }, + ], + }), + }) + + const p2 = Instance.provide({ + directory: two.path, + fn: () => + Question.ask({ + sessionID: SessionID.make("ses_two"), + questions: [ + { + question: "Question 2?", + header: "Q2", + options: [{ label: "B", description: "B" }], + }, + ], + }), + }) + + const onePending = await Instance.provide({ + directory: one.path, + fn: () => Question.list(), + }) + const twoPending = await Instance.provide({ + directory: two.path, + fn: () => Question.list(), + }) + + expect(onePending.length).toBe(1) + expect(twoPending.length).toBe(1) + expect(onePending[0].sessionID).toBe(SessionID.make("ses_one")) + expect(twoPending[0].sessionID).toBe(SessionID.make("ses_two")) + + await Instance.provide({ + directory: one.path, + fn: () => Question.reject(onePending[0].id), + }) + await Instance.provide({ + directory: two.path, + fn: () => Question.reject(twoPending[0].id), + }) + + await p1.catch(() => {}) + await p2.catch(() => {}) +}) + +test("pending question rejects on instance dispose", async () => { + await using tmp = await tmpdir({ git: true }) + + const ask = Instance.provide({ + directory: tmp.path, + fn: () => { + return Question.ask({ + sessionID: SessionID.make("ses_dispose"), + questions: [ + { + question: "Dispose me?", + header: "Dispose", + options: [{ label: "Yes", description: "Yes" }], + }, + ], + }) + }, + }) + const result = ask.then( + () => "resolved" as const, + (err) => err, + ) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const pending = await Question.list() + expect(pending).toHaveLength(1) + await Instance.dispose() + }, + }) + + expect(await result).toBeInstanceOf(Question.RejectedError) +}) + +test("pending question rejects on instance reload", async () => { + await using tmp = await tmpdir({ git: true }) + + const ask = Instance.provide({ + directory: tmp.path, + fn: () => { + return Question.ask({ + sessionID: SessionID.make("ses_reload"), + questions: [ + { + question: "Reload me?", + header: "Reload", + options: [{ label: "Yes", description: "Yes" }], + }, + ], + }) + }, + }) + const result = ask.then( + () => "resolved" as const, + (err) => err, + ) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const pending = await Question.list() + expect(pending).toHaveLength(1) + await Instance.reload({ directory: tmp.path }) + }, + }) + + expect(await result).toBeInstanceOf(Question.RejectedError) +}) diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index 5be5d02450d..fc8d511509f 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -7,7 +7,7 @@ test("ShareNext.request uses legacy share API without active org account", async const originalActive = Account.active const originalConfigGet = Config.get - Account.active = mock(() => undefined) + Account.active = mock(async () => undefined) Config.get = mock(async () => ({ enterprise: { url: "https://legacy-share.example.com" } })) try { @@ -29,7 +29,7 @@ test("ShareNext.request uses org share API with auth headers when account is act const originalActive = Account.active const originalToken = Account.token - Account.active = mock(() => ({ + Account.active = mock(async () => ({ id: AccountID.make("account-1"), email: "user@example.com", url: "https://control.example.com", @@ -59,7 +59,7 @@ test("ShareNext.request fails when org account has no token", async () => { const originalActive = Account.active const originalToken = Account.token - Account.active = mock(() => ({ + Account.active = mock(async () => ({ id: AccountID.make("account-1"), email: "user@example.com", url: "https://control.example.com", diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index 2264723a090..12e16f86a1a 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -1,10 +1,14 @@ -import { test, expect } from "bun:test" +import { afterEach, test, expect } from "bun:test" import { Skill } from "../../src/skill" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" import path from "path" import fs from "fs/promises" +afterEach(async () => { + await Instance.disposeAll() +}) + async function createGlobalSkill(homeDir: string) { const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill") await fs.mkdir(skillDir, { recursive: true }) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 20305028764..bf54feb4723 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -1,4 +1,4 @@ -import { test, expect } from "bun:test" +import { afterEach, test, expect } from "bun:test" import { $ } from "bun" import fs from "fs/promises" import path from "path" @@ -12,6 +12,10 @@ import { tmpdir } from "../fixture/fixture" // This helper does the same for expected values so assertions match cross-platform. const fwd = (...parts: string[]) => path.join(...parts).replaceAll("\\", "/") +afterEach(async () => { + await Instance.disposeAll() +}) + async function bootstrap() { return tmpdir({ git: true, diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index a5c7cec917f..4d680d494f3 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -5,7 +5,7 @@ import { BashTool } from "../../src/tool/bash" import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" -import type { PermissionNext } from "../../src/permission" +import type { Permission } from "../../src/permission" import { Truncate } from "../../src/tool/truncate" import { SessionID, MessageID } from "../../src/session/schema" @@ -49,10 +49,10 @@ describe("tool.bash permissions", () => { directory: tmp.path, fn: async () => { const bash = await BashTool.init() - const requests: Array> = [] + const requests: Array> = [] const testCtx = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { requests.push(req) }, } @@ -76,10 +76,10 @@ describe("tool.bash permissions", () => { directory: tmp.path, fn: async () => { const bash = await BashTool.init() - const requests: Array> = [] + const requests: Array> = [] const testCtx = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { requests.push(req) }, } @@ -104,10 +104,10 @@ describe("tool.bash permissions", () => { directory: tmp.path, fn: async () => { const bash = await BashTool.init() - const requests: Array> = [] + const requests: Array> = [] const testCtx = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { requests.push(req) }, } @@ -130,10 +130,10 @@ describe("tool.bash permissions", () => { directory: tmp.path, fn: async () => { const bash = await BashTool.init() - const requests: Array> = [] + const requests: Array> = [] const testCtx = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { requests.push(req) }, } @@ -163,10 +163,10 @@ describe("tool.bash permissions", () => { directory: tmp.path, fn: async () => { const bash = await BashTool.init() - const requests: Array> = [] + const requests: Array> = [] const testCtx = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { requests.push(req) }, } @@ -193,10 +193,10 @@ describe("tool.bash permissions", () => { directory: tmp.path, fn: async () => { const bash = await BashTool.init() - const requests: Array> = [] + const requests: Array> = [] const testCtx = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { requests.push(req) }, } @@ -223,10 +223,10 @@ describe("tool.bash permissions", () => { directory: tmp.path, fn: async () => { const bash = await BashTool.init() - const requests: Array> = [] + const requests: Array> = [] const testCtx = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { requests.push(req) }, } @@ -250,10 +250,10 @@ describe("tool.bash permissions", () => { directory: tmp.path, fn: async () => { const bash = await BashTool.init() - const requests: Array> = [] + const requests: Array> = [] const testCtx = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { requests.push(req) }, } @@ -276,10 +276,10 @@ describe("tool.bash permissions", () => { directory: tmp.path, fn: async () => { const bash = await BashTool.init() - const requests: Array> = [] + const requests: Array> = [] const testCtx = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { requests.push(req) }, } @@ -297,10 +297,10 @@ describe("tool.bash permissions", () => { directory: tmp.path, fn: async () => { const bash = await BashTool.init() - const requests: Array> = [] + const requests: Array> = [] const testCtx = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { requests.push(req) }, } diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 7b6784cf49a..f6b1ee5c926 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from "bun:test" +import { afterEach, describe, test, expect } from "bun:test" import path from "path" import fs from "fs/promises" import { EditTool } from "../../src/tool/edit" @@ -18,6 +18,10 @@ const ctx = { ask: async () => {}, } +afterEach(async () => { + await Instance.disposeAll() +}) + async function touch(file: string, time: number) { const date = new Date(time) await fs.utimes(file, date, date) diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index 229901a7228..0188cbada04 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -3,7 +3,7 @@ import path from "path" import type { Tool } from "../../src/tool/tool" import { Instance } from "../../src/project/instance" import { assertExternalDirectory } from "../../src/tool/external-directory" -import type { PermissionNext } from "../../src/permission" +import type { Permission } from "../../src/permission" import { SessionID, MessageID } from "../../src/session/schema" const baseCtx: Omit = { @@ -18,7 +18,7 @@ const baseCtx: Omit = { describe("tool.assertExternalDirectory", () => { test("no-ops for empty target", async () => { - const requests: Array> = [] + const requests: Array> = [] const ctx: Tool.Context = { ...baseCtx, ask: async (req) => { @@ -37,7 +37,7 @@ describe("tool.assertExternalDirectory", () => { }) test("no-ops for paths inside Instance.directory", async () => { - const requests: Array> = [] + const requests: Array> = [] const ctx: Tool.Context = { ...baseCtx, ask: async (req) => { @@ -56,7 +56,7 @@ describe("tool.assertExternalDirectory", () => { }) test("asks with a single canonical glob", async () => { - const requests: Array> = [] + const requests: Array> = [] const ctx: Tool.Context = { ...baseCtx, ask: async (req) => { @@ -82,7 +82,7 @@ describe("tool.assertExternalDirectory", () => { }) test("uses target directory when kind=directory", async () => { - const requests: Array> = [] + const requests: Array> = [] const ctx: Tool.Context = { ...baseCtx, ask: async (req) => { @@ -108,7 +108,7 @@ describe("tool.assertExternalDirectory", () => { }) test("skips prompting when bypass=true", async () => { - const requests: Array> = [] + const requests: Array> = [] const ctx: Tool.Context = { ...baseCtx, ask: async (req) => { diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index cfeb597fcec..06a7f9a7069 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -1,15 +1,19 @@ -import { describe, expect, test } from "bun:test" +import { afterEach, describe, expect, test } from "bun:test" import path from "path" import { ReadTool } from "../../src/tool/read" import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" -import { PermissionNext } from "../../src/permission" +import { Permission } from "../../src/permission" import { Agent } from "../../src/agent/agent" import { SessionID, MessageID } from "../../src/session/schema" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") +afterEach(async () => { + await Instance.disposeAll() +}) + const ctx = { sessionID: SessionID.make("ses_test"), messageID: MessageID.make(""), @@ -65,10 +69,10 @@ describe("tool.read external_directory permission", () => { directory: tmp.path, fn: async () => { const read = await ReadTool.init() - const requests: Array> = [] + const requests: Array> = [] const testCtx = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { requests.push(req) }, } @@ -91,10 +95,10 @@ describe("tool.read external_directory permission", () => { directory: tmp.path, fn: async () => { const read = await ReadTool.init() - const requests: Array> = [] + const requests: Array> = [] const testCtx = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { requests.push(req) }, } @@ -112,10 +116,10 @@ describe("tool.read external_directory permission", () => { directory: tmp.path, fn: async () => { const read = await ReadTool.init() - const requests: Array> = [] + const requests: Array> = [] const testCtx = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { requests.push(req) }, } @@ -138,10 +142,10 @@ describe("tool.read external_directory permission", () => { directory: tmp.path, fn: async () => { const read = await ReadTool.init() - const requests: Array> = [] + const requests: Array> = [] const testCtx = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { requests.push(req) }, } @@ -176,14 +180,14 @@ describe("tool.read env file permissions", () => { let askedForEnv = false const ctxWithPermissions = { ...ctx, - ask: async (req: Omit) => { + ask: async (req: Omit) => { for (const pattern of req.patterns) { - const rule = PermissionNext.evaluate(req.permission, pattern, agent.permission) + const rule = Permission.evaluate(req.permission, pattern, agent.permission) if (rule.action === "ask" && req.permission === "read") { askedForEnv = true } if (rule.action === "deny") { - throw new PermissionNext.DeniedError({ ruleset: agent.permission }) + throw new Permission.DeniedError({ ruleset: agent.permission }) } } }, diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 706a9e12caf..c9951ef198c 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -1,10 +1,14 @@ -import { describe, expect, test } from "bun:test" +import { afterEach, describe, expect, test } from "bun:test" import path from "path" import fs from "fs/promises" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { ToolRegistry } from "../../src/tool/registry" +afterEach(async () => { + await Instance.disposeAll() +}) + describe("tool.registry", () => { test("loads tools from .opencode/tool (singular)", async () => { await using tmp = await tmpdir({ diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index f622341d333..ffae223f98a 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -1,7 +1,7 @@ -import { describe, expect, test } from "bun:test" +import { afterEach, describe, expect, test } from "bun:test" import path from "path" import { pathToFileURL } from "url" -import type { PermissionNext } from "../../src/permission" +import type { Permission } from "../../src/permission" import type { Tool } from "../../src/tool/tool" import { Instance } from "../../src/project/instance" import { SkillTool } from "../../src/tool/skill" @@ -18,6 +18,10 @@ const baseCtx: Omit = { metadata: () => {}, } +afterEach(async () => { + await Instance.disposeAll() +}) + describe("tool.skill", () => { test("description lists skill location URL", async () => { await using tmp = await tmpdir({ @@ -133,7 +137,7 @@ Use this skill. directory: tmp.path, fn: async () => { const tool = await SkillTool.init() - const requests: Array> = [] + const requests: Array> = [] const ctx: Tool.Context = { ...baseCtx, ask: async (req) => { diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index df319d8de1e..aae48a30ab3 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -1,9 +1,13 @@ -import { describe, expect, test } from "bun:test" +import { afterEach, describe, expect, test } from "bun:test" import { Agent } from "../../src/agent/agent" import { Instance } from "../../src/project/instance" import { TaskTool } from "../../src/tool/task" import { tmpdir } from "../fixture/fixture" +afterEach(async () => { + await Instance.disposeAll() +}) + describe("tool.task", () => { test("description sorts subagents by name and is stable across calls", async () => { await using tmp = await tmpdir({ diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index 032f0bfee25..dba083c5123 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -1,8 +1,7 @@ import { describe, test, expect } from "bun:test" import { NodeFileSystem } from "@effect/platform-node" import { Effect, FileSystem, Layer } from "effect" -import { Truncate } from "../../src/tool/truncate" -import { Truncate as TruncateSvc } from "../../src/tool/truncate-effect" +import { Truncate, Truncate as TruncateSvc } from "../../src/tool/truncate" import { Identifier } from "../../src/id/id" import { Process } from "../../src/util/process" import { Filesystem } from "../../src/util/filesystem" @@ -129,7 +128,7 @@ describe("Truncate", () => { }) test("loads truncate effect in a fresh process", async () => { - const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate-effect.ts")], { + const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate.ts")], { cwd: ROOT, }) diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index af002a39100..97939c10519 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from "bun:test" +import { afterEach, describe, test, expect } from "bun:test" import path from "path" import fs from "fs/promises" import { WriteTool } from "../../src/tool/write" @@ -17,6 +17,10 @@ const ctx = { ask: async () => {}, } +afterEach(async () => { + await Instance.disposeAll() +}) + describe("tool.write", () => { describe("new file creation", () => { test("writes content to new file", async () => {