diff options
35 files changed, 2034 insertions, 2059 deletions
diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md index f28150622..e2a0c918d 100644 --- a/packages/opencode/AGENTS.md +++ b/packages/opencode/AGENTS.md @@ -9,71 +9,55 @@ - **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`. - **Tests**: migration tests should read the per-folder layout (no `_journal.json`). -# opencode Effect guide +# opencode Effect rules -Instructions to follow when writing Effect. +Use these rules when writing or migrating Effect code. -## Schemas +See `specs/effect-migration.md` for the compact pattern reference and examples. -- Use `Schema.Class` for data types with multiple fields. -- Use branded schemas (`Schema.brand`) for single-value types. - -## Services - -- Services use `ServiceMap.Service<ServiceName, ServiceName.Service>()("@console/<Name>")`. -- In `Layer.effect`, always return service implementations with `ServiceName.of({ ... })`, never a plain object. - -## Errors - -- Use `Schema.TaggedErrorClass` for typed errors. -- For defect-like causes, use `Schema.Defect` instead of `unknown`. -- In `Effect.gen`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches. - -## Effects +## Core - Use `Effect.gen(function* () { ... })` for composition. -- Use `Effect.fn("ServiceName.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers. -- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary `flow` or outer `.pipe()` wrappers. -- **`Effect.callback`** (not `Effect.async`) for callback-based APIs. The classic `Effect.async` was renamed to `Effect.callback` in effect-smol/v4. - -## Time - +- Use `Effect.fn("Domain.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers. +- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary outer `.pipe()` wrappers. +- Use `Effect.callback` for callback-based APIs. - Prefer `DateTime.nowAsDate` over `new Date(yield* Clock.currentTimeMillis)` when you need a `Date`. -## Errors +## Schemas and errors + +- Use `Schema.Class` for multi-field data. +- Use branded schemas (`Schema.brand`) for single-value types. +- Use `Schema.TaggedErrorClass` for typed errors. +- Use `Schema.Defect` instead of `unknown` for defect-like causes. +- In `Effect.gen` / `Effect.fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches. -- In `Effect.gen/fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches. +## Runtime vs Instances -## Instance-scoped Effect services +- Use the shared runtime for process-wide services with one lifecycle for the whole app. +- Use `src/effect/instances.ts` for per-directory or per-project services that need `InstanceContext`, per-instance state, or per-instance cleanup. +- If two open directories should not share one copy of the service, it belongs in `Instances`. +- Instance-scoped services should read context from `InstanceContext`, not `Instance.*` globals. -Services that need per-directory lifecycle (created/destroyed per instance) go through the `Instances` LayerMap: +## Preferred Effect services -1. Define a `ServiceMap.Service` with a `static readonly layer` (see `FileWatcherService`, `QuestionService`, `PermissionService`, `ProviderAuthService`). -2. Add it to `InstanceServices` union and `Layer.mergeAll(...)` in `src/effect/instances.ts`. -3. Use `InstanceContext` inside the layer to read `directory` and `project` instead of `Instance.*` globals. -4. Call from legacy code via `runPromiseInstance(MyService.use((s) => s.method()))`. +- In effectified services, prefer yielding existing Effect services over dropping down to ad hoc platform APIs. +- Prefer `FileSystem.FileSystem` instead of raw `fs/promises` for effectful file I/O. +- Prefer `ChildProcessSpawner.ChildProcessSpawner` with `ChildProcess.make(...)` instead of custom process wrappers. +- Prefer `HttpClient.HttpClient` instead of raw `fetch`. +- Prefer `Path.Path`, `Config`, `Clock`, and `DateTime` when those concerns are already inside Effect code. +- For background loops or scheduled tasks, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition. -### Instance.bind — ALS context for native callbacks +## Instance.bind — ALS for native callbacks -`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and returns a wrapper that restores it synchronously when called. +`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called. -**Use it** when passing callbacks to native C/C++ addons (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish`, `Instance.state()`, or anything that reads `Instance.directory`. +Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish`, `Instance.state()`, or anything that reads `Instance.directory`. -**Don't need it** for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers — Node.js ALS propagates through those automatically. +You do not need it for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers. ```typescript -// Native addon callback — needs Instance.bind const cb = Instance.bind((err, evts) => { Bus.publish(MyEvent, { ... }) }) nativeAddon.subscribe(dir, cb) ``` - -## Flag → Effect.Config migration - -Flags in `src/flag/flag.ts` are being migrated from static `truthy(...)` reads to `Config.boolean(...).pipe(Config.withDefault(false))` as their consumers get effectified. - -- Effectful flags return `Config<boolean>` and are read with `yield*` inside `Effect.gen`. -- The default `ConfigProvider` reads from `process.env`, so env vars keep working. -- Tests can override via `ConfigProvider.layer(ConfigProvider.fromUnknown({ ... }))`. -- Keep all flags in `flag.ts` as the single registry — just change the implementation from `truthy()` to `Config.boolean()` when the consumer moves to Effect. diff --git a/packages/opencode/specs/effect-migration.md b/packages/opencode/specs/effect-migration.md new file mode 100644 index 000000000..4f195917f --- /dev/null +++ b/packages/opencode/specs/effect-migration.md @@ -0,0 +1,144 @@ +# Effect patterns + +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 `src/effect/instances.ts` for services that are created per directory or need `InstanceContext`, per-project state, or per-instance cleanup. + +- Shared runtime: config readers, stateless helpers, global clients +- Instance-scoped: watchers, per-project caches, session state, project-bound background work + +Rule of thumb: if two open directories should not share one copy of the service, it belongs in `Instances`. + +## Service shape + +For a fully migrated module, use the public namespace directly: + +```ts +export namespace Foo { + export interface Interface { + readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError> + } + + export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + return Service.of({ + get: Effect.fn("Foo.get")(function* (id) { + return yield* ... + }), + }) + }), + ) + + export const defaultLayer = layer.pipe(Layer.provide(FooRepo.defaultLayer)) +} +``` + +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 + +Prefer a single namespace whenever possible. + +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. + +```ts +export namespace FooEffect { + export interface Interface { + readonly get: (id: FooID) => Effect.Effect<Foo, FooError> + } + + export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {} + + 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))) + } +} +``` + +Remove the `Effect` suffix when the boundary split is gone. + +## Scheduled Tasks + +For loops or periodic work, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition. + +## Preferred Effect services + +In effectified services, prefer yielding existing Effect services over dropping down to ad hoc platform APIs. + +Prefer these first: + +- `FileSystem.FileSystem` instead of raw `fs/promises` for effectful file I/O +- `ChildProcessSpawner.ChildProcessSpawner` with `ChildProcess.make(...)` instead of custom process wrappers +- `HttpClient.HttpClient` instead of raw `fetch` +- `Path.Path` instead of mixing path helpers into service code when you already need a path service +- `Config` for effect-native configuration reads +- `Clock` / `DateTime` for time reads inside effects + +## Child processes + +For child process work in services, yield `ChildProcessSpawner.ChildProcessSpawner` in the layer and use `ChildProcess.make(...)`. + +Keep shelling-out code inside the service, not in callers. + +## Shared leaf models + +Shared schema or model files can stay outside the service namespace when lower layers also depend on them. + +That is fine for leaf files like `schema.ts`. Keep the service surface in the owning namespace. + +## 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` + +Still open and likely worth migrating: + +- [ ] `Plugin` +- [ ] `ToolRegistry` +- [ ] `Pty` +- [ ] `Worktree` +- [ ] `Installation` +- [ ] `Bus` +- [ ] `Command` +- [ ] `Config` +- [ ] `Session` +- [ ] `SessionProcessor` +- [ ] `SessionPrompt` +- [ ] `SessionCompaction` +- [ ] `Provider` +- [ ] `Project` +- [ ] `LSP` +- [ ] `MCP` diff --git a/packages/opencode/src/account/service.ts b/packages/opencode/src/account/effect.ts index 87e95c8f4..444676046 100644 --- a/packages/opencode/src/account/service.ts +++ b/packages/opencode/src/account/effect.ts @@ -108,8 +108,8 @@ const mapAccountServiceError = ), ) -export namespace AccountService { - export interface Service { +export namespace AccountEffect { + export interface Interface { readonly active: () => Effect.Effect<Option.Option<Account>, AccountError> readonly list: () => Effect.Effect<Account[], AccountError> readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError> @@ -124,11 +124,11 @@ export namespace AccountService { readonly login: (url: string) => Effect.Effect<Login, AccountError> readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError> } -} -export class AccountService extends ServiceMap.Service<AccountService, AccountService.Service>()("@opencode/Account") { - static readonly layer: Layer.Layer<AccountService, never, AccountRepo | HttpClient.HttpClient> = Layer.effect( - AccountService, + export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {} + + export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect( + Service, Effect.gen(function* () { const repo = yield* AccountRepo const http = yield* HttpClient.HttpClient @@ -148,8 +148,6 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe mapAccountServiceError("HTTP request failed"), ) - // Returns a usable access token for a stored account row, refreshing and - // persisting it when the cached token has expired. const resolveToken = Effect.fnUntraced(function* (row: AccountRow) { const now = yield* Clock.currentTimeMillis if (row.token_expiry && row.token_expiry > now) return row.access_token @@ -218,11 +216,11 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe ) }) - const token = Effect.fn("AccountService.token")((accountID: AccountID) => + const token = Effect.fn("Account.token")((accountID: AccountID) => resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))), ) - const orgsByAccount = Effect.fn("AccountService.orgsByAccount")(function* () { + const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () { const accounts = yield* repo.list() const [errors, results] = yield* Effect.partition( accounts, @@ -237,7 +235,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe return results }) - const orgs = Effect.fn("AccountService.orgs")(function* (accountID: AccountID) { + const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) { const resolved = yield* resolveAccess(accountID) if (Option.isNone(resolved)) return [] @@ -246,7 +244,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe return yield* fetchOrgs(account.url, accessToken) }) - const config = Effect.fn("AccountService.config")(function* (accountID: AccountID, orgID: OrgID) { + const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) { const resolved = yield* resolveAccess(accountID) if (Option.isNone(resolved)) return Option.none() @@ -270,7 +268,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe return Option.some(parsed.config) }) - const login = Effect.fn("AccountService.login")(function* (server: string) { + const login = Effect.fn("Account.login")(function* (server: string) { const response = yield* executeEffectOk( HttpClientRequest.post(`${server}/auth/device/code`).pipe( HttpClientRequest.acceptJson, @@ -291,7 +289,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe }) }) - const poll = Effect.fn("AccountService.poll")(function* (input: Login) { + const poll = Effect.fn("Account.poll")(function* (input: Login) { const response = yield* executeEffectOk( HttpClientRequest.post(`${input.server}/auth/device/token`).pipe( HttpClientRequest.acceptJson, @@ -337,7 +335,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe return new PollSuccess({ email: account.email }) }) - return AccountService.of({ + return Service.of({ active: repo.active, list: repo.list, orgsByAccount, @@ -352,8 +350,5 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe }), ) - static readonly defaultLayer = AccountService.layer.pipe( - Layer.provide(AccountRepo.layer), - Layer.provide(FetchHttpClient.layer), - ) + 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 ed4c3d879..3a9d758e2 100644 --- a/packages/opencode/src/account/index.ts +++ b/packages/opencode/src/account/index.ts @@ -5,20 +5,20 @@ import { type AccountError, type AccessToken, AccountID, - AccountService, + AccountEffect, OrgID, -} from "./service" +} from "./effect" -export { AccessToken, AccountID, OrgID } from "./service" +export { AccessToken, AccountID, OrgID } from "./effect" import { runtime } from "@/effect/runtime" -function runSync<A>(f: (service: AccountService.Service) => Effect.Effect<A, AccountError>) { - return runtime.runSync(AccountService.use(f)) +function runSync<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) { + return runtime.runSync(AccountEffect.Service.use(f)) } -function runPromise<A>(f: (service: AccountService.Service) => Effect.Effect<A, AccountError>) { - return runtime.runPromise(AccountService.use(f)) +function runPromise<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) { + return runtime.runPromise(AccountEffect.Service.use(f)) } export namespace Account { diff --git a/packages/opencode/src/auth/service.ts b/packages/opencode/src/auth/effect.ts index 100a132b8..043b9002e 100644 --- a/packages/opencode/src/auth/service.ts +++ b/packages/opencode/src/auth/effect.ts @@ -28,31 +28,31 @@ export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({ export const Info = Schema.Union([Oauth, Api, WellKnown]) export type Info = Schema.Schema.Type<typeof Info> -export class AuthServiceError extends Schema.TaggedErrorClass<AuthServiceError>()("AuthServiceError", { +export class AuthError extends Schema.TaggedErrorClass<AuthError>()("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 AuthServiceError({ message, cause }) +const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause }) -export namespace AuthService { - export interface Service { - readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthServiceError> - readonly all: () => Effect.Effect<Record<string, Info>, AuthServiceError> - readonly set: (key: string, info: Info) => Effect.Effect<void, AuthServiceError> - readonly remove: (key: string) => Effect.Effect<void, AuthServiceError> +export namespace AuthEffect { + export interface Interface { + readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError> + readonly all: () => Effect.Effect<Record<string, Info>, AuthError> + readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError> + readonly remove: (key: string) => Effect.Effect<void, AuthError> } -} -export class AuthService extends ServiceMap.Service<AuthService, AuthService.Service>()("@opencode/Auth") { - static readonly layer = Layer.effect( - AuthService, + export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {} + + export const layer = Layer.effect( + Service, Effect.gen(function* () { const decode = Schema.decodeUnknownOption(Info) - const all = Effect.fn("AuthService.all")(() => + const all = Effect.fn("Auth.all")(() => Effect.tryPromise({ try: async () => { const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({})) @@ -62,11 +62,11 @@ export class AuthService extends ServiceMap.Service<AuthService, AuthService.Ser }), ) - const get = Effect.fn("AuthService.get")(function* (providerID: string) { + const get = Effect.fn("Auth.get")(function* (providerID: string) { return (yield* all())[providerID] }) - const set = Effect.fn("AuthService.set")(function* (key: string, info: Info) { + 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] @@ -77,7 +77,7 @@ export class AuthService extends ServiceMap.Service<AuthService, AuthService.Ser }) }) - const remove = Effect.fn("AuthService.remove")(function* (key: string) { + const remove = Effect.fn("Auth.remove")(function* (key: string) { const norm = key.replace(/\/+$/, "") const data = yield* all() delete data[key] @@ -88,14 +88,8 @@ export class AuthService extends ServiceMap.Service<AuthService, AuthService.Ser }) }) - return AuthService.of({ - get, - all, - set, - remove, - }) + return Service.of({ get, all, set, remove }) }), ) - static readonly defaultLayer = AuthService.layer } diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 79e9e615d..6f588e937 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,12 +1,12 @@ import { Effect } from "effect" import z from "zod" import { runtime } from "@/effect/runtime" -import * as S from "./service" +import * as S from "./effect" -export { OAUTH_DUMMY_KEY } from "./service" +export { OAUTH_DUMMY_KEY } from "./effect" -function runPromise<A>(f: (service: S.AuthService.Service) => Effect.Effect<A, S.AuthServiceError>) { - return runtime.runPromise(S.AuthService.use(f)) +function runPromise<A>(f: (service: S.AuthEffect.Interface) => Effect.Effect<A, S.AuthError>) { + return runtime.runPromise(S.AuthEffect.Service.use(f)) } export namespace Auth { diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts index b2256837d..c2b47da11 100644 --- a/packages/opencode/src/cli/cmd/account.ts +++ b/packages/opencode/src/cli/cmd/account.ts @@ -2,7 +2,7 @@ import { cmd } from "./cmd" import { Duration, Effect, Match, Option } from "effect" import { UI } from "../ui" import { runtime } from "@/effect/runtime" -import { AccountID, AccountService, OrgID, PollExpired, type PollResult } from "@/account/service" +import { AccountID, AccountEffect, OrgID, PollExpired, type PollResult } from "@/account/effect" import { type AccountError } from "@/account/schema" import * as Prompt from "../effect/prompt" import open from "open" @@ -17,7 +17,7 @@ const isActiveOrgChoice = ( ) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID const loginEffect = Effect.fn("login")(function* (url: string) { - const service = yield* AccountService + const service = yield* AccountEffect.Service yield* Prompt.intro("Log in") const login = yield* service.login(url) @@ -58,7 +58,7 @@ const loginEffect = Effect.fn("login")(function* (url: string) { }) const logoutEffect = Effect.fn("logout")(function* (email?: string) { - const service = yield* AccountService + const service = yield* AccountEffect.Service const accounts = yield* service.list() if (accounts.length === 0) return yield* println("Not logged in") @@ -98,7 +98,7 @@ interface OrgChoice { } const switchEffect = Effect.fn("switch")(function* () { - const service = yield* AccountService + const service = yield* AccountEffect.Service const groups = yield* service.orgsByAccount() if (groups.length === 0) return yield* println("Not logged in") @@ -129,7 +129,7 @@ const switchEffect = Effect.fn("switch")(function* () { }) const orgsEffect = Effect.fn("orgs")(function* () { - const service = yield* AccountService + const service = yield* AccountEffect.Service const groups = yield* service.orgsByAccount() if (groups.length === 0) return yield* println("No accounts found") diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index 3a1fb0cdf..c05458d5d 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -1,31 +1,31 @@ import { Effect, Layer, LayerMap, ServiceMap } from "effect" -import { FileService } from "@/file" -import { FileTimeService } from "@/file/time" -import { FileWatcherService } from "@/file/watcher" -import { FormatService } from "@/format" -import { PermissionEffect } from "@/permission/service" +import { File } from "@/file" +import { FileTime } from "@/file/time" +import { FileWatcher } from "@/file/watcher" +import { Format } from "@/format" +import { PermissionNext } from "@/permission" import { Instance } from "@/project/instance" -import { VcsService } from "@/project/vcs" -import { ProviderAuthService } from "@/provider/auth-service" -import { QuestionService } from "@/question/service" -import { SkillService } from "@/skill/skill" -import { SnapshotService } from "@/snapshot" +import { Vcs } from "@/project/vcs" +import { ProviderAuth } from "@/provider/auth" +import { Question } from "@/question" +import { Skill } from "@/skill/skill" +import { Snapshot } from "@/snapshot" import { InstanceContext } from "./instance-context" import { registerDisposer } from "./instance-registry" export { InstanceContext } from "./instance-context" export type InstanceServices = - | QuestionService - | PermissionEffect.Service - | ProviderAuthService - | FileWatcherService - | VcsService - | FileTimeService - | FormatService - | FileService - | SkillService - | SnapshotService + | Question.Service + | PermissionNext.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 @@ -36,16 +36,16 @@ export type InstanceServices = function lookup(_key: string) { const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current)) return Layer.mergeAll( - Layer.fresh(QuestionService.layer), - Layer.fresh(PermissionEffect.layer), - Layer.fresh(ProviderAuthService.layer), - Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie), - Layer.fresh(VcsService.layer), - Layer.fresh(FileTimeService.layer).pipe(Layer.orDie), - Layer.fresh(FormatService.layer), - Layer.fresh(FileService.layer), - Layer.fresh(SkillService.layer), - Layer.fresh(SnapshotService.layer), + Layer.fresh(Question.layer), + Layer.fresh(PermissionNext.layer), + Layer.fresh(ProviderAuth.defaultLayer), + Layer.fresh(FileWatcher.layer).pipe(Layer.orDie), + Layer.fresh(Vcs.layer), + Layer.fresh(FileTime.layer).pipe(Layer.orDie), + Layer.fresh(Format.layer), + Layer.fresh(File.layer), + Layer.fresh(Skill.defaultLayer), + Layer.fresh(Snapshot.defaultLayer), ).pipe(Layer.provide(ctx)) } @@ -55,9 +55,7 @@ export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<s static readonly layer = Layer.effect( Instances, Effect.gen(function* () { - const layerMap = yield* LayerMap.make(lookup, { - idleTimeToLive: Infinity, - }) + 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) diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts index a55956bfd..f52203b22 100644 --- a/packages/opencode/src/effect/runtime.ts +++ b/packages/opencode/src/effect/runtime.ts @@ -1,6 +1,6 @@ import { Effect, Layer, ManagedRuntime } from "effect" -import { AccountService } from "@/account/service" -import { AuthService } from "@/auth/service" +import { AccountEffect } from "@/account/effect" +import { AuthEffect } from "@/auth/effect" import { Instances } from "@/effect/instances" import type { InstanceServices } from "@/effect/instances" import { TruncateEffect } from "@/tool/truncate-effect" @@ -8,10 +8,10 @@ import { Instance } from "@/project/instance" export const runtime = ManagedRuntime.make( Layer.mergeAll( - AccountService.defaultLayer, // + AccountEffect.defaultLayer, // TruncateEffect.defaultLayer, Instances.layer, - ).pipe(Layer.provideMerge(AuthService.defaultLayer)), + ).pipe(Layer.provideMerge(AuthEffect.layer)), ) export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) { diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index cee03e091..6e9b91727 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -1,272 +1,20 @@ import { BusEvent } from "@/bus/bus-event" -import z from "zod" +import { InstanceContext } from "@/effect/instance-context" +import { runPromiseInstance } from "@/effect/runtime" +import { git } from "@/util/git" +import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect" import { formatPatch, structuredPatch } from "diff" -import path from "path" import fs from "fs" -import ignore from "ignore" -import { Log } from "../util/log" -import { Filesystem } from "../util/filesystem" -import { Instance } from "../project/instance" -import { Ripgrep } from "./ripgrep" import fuzzysort from "fuzzysort" +import ignore from "ignore" +import path from "path" +import z from "zod" import { Global } from "../global" -import { git } from "@/util/git" +import { Instance } from "../project/instance" +import { Filesystem } from "../util/filesystem" +import { Log } from "../util/log" import { Protected } from "./protected" -import { InstanceContext } from "@/effect/instance-context" -import { Effect, Layer, ServiceMap } from "effect" -import { runPromiseInstance } from "@/effect/runtime" - -const log = Log.create({ service: "file" }) - -const binaryExtensions = 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 imageExtensions = 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 textExtensions = 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 textNames = new Set([ - "dockerfile", - "makefile", - ".gitignore", - ".gitattributes", - ".editorconfig", - ".npmrc", - ".nvmrc", - ".prettierrc", - ".eslintrc", -]) - -function isImageByExtension(filepath: string): boolean { - const ext = path.extname(filepath).toLowerCase().slice(1) - return imageExtensions.has(ext) -} - -function isTextByExtension(filepath: string): boolean { - const ext = path.extname(filepath).toLowerCase().slice(1) - return textExtensions.has(ext) -} - -function isTextByName(filepath: string): boolean { - const name = path.basename(filepath).toLowerCase() - return textNames.has(name) -} - -function getImageMimeType(filepath: string): string { - const ext = path.extname(filepath).toLowerCase().slice(1) - const mimeTypes: Record<string, string> = { - 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", - } - return mimeTypes[ext] || "image/" + ext -} - -function isBinaryByExtension(filepath: string): boolean { - const ext = path.extname(filepath).toLowerCase().slice(1) - return binaryExtensions.has(ext) -} - -function isImage(mimeType: string): boolean { - return mimeType.startsWith("image/") -} - -function shouldEncode(mimeType: string): boolean { - 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 parts = type.split("/", 2) - const top = parts[0] - - const tops = ["image", "audio", "video", "font", "model", "multipart"] - if (tops.includes(top)) return true - - return false -} +import { Ripgrep } from "./ripgrep" export namespace File { export const Info = z @@ -336,28 +84,270 @@ export namespace File { } export function init() { - return runPromiseInstance(FileService.use((s) => s.init())) + return runPromiseInstance(Service.use((svc) => svc.init())) } export async function status() { - return runPromiseInstance(FileService.use((s) => s.status())) + return runPromiseInstance(Service.use((svc) => svc.status())) } export async function read(file: string): Promise<Content> { - return runPromiseInstance(FileService.use((s) => s.read(file))) + return runPromiseInstance(Service.use((svc) => svc.read(file))) } export async function list(dir?: string) { - return runPromiseInstance(FileService.use((s) => s.list(dir))) + return runPromiseInstance(Service.use((svc) => svc.list(dir))) } export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { - return runPromiseInstance(FileService.use((s) => s.search(input))) + return runPromiseInstance(Service.use((svc) => svc.search(input))) + } + + 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<string, string> = { + 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 namespace FileService { - export interface Service { + export interface Interface { readonly init: () => Effect.Effect<void> readonly status: () => Effect.Effect<File.Info[]> readonly read: (file: string) => Effect.Effect<File.Content> @@ -369,89 +359,83 @@ export namespace FileService { type?: "file" | "directory" }) => Effect.Effect<string[]> } -} -export class FileService extends ServiceMap.Service<FileService, FileService.Service>()("@opencode/File") { - static readonly layer = Layer.effect( - FileService, + export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {} + + export const layer = Layer.effect( + Service, Effect.gen(function* () { const instance = yield* InstanceContext - - // File cache state - type Entry = { files: string[]; dirs: string[] } let cache: Entry = { files: [], dirs: [] } - let task: Promise<void> | undefined - const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global" - function kick() { - if (task) return task - task = (async () => { - // Disable scanning if in root of file system - if (instance.directory === path.parse(instance.directory).root) return - const next: Entry = { files: [], dirs: [] } - try { - if (isGlobalHome) { - const dirs = new Set<string>() - 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 + "/") - } + 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<string>() + 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 set = new Set<string>() - 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 (set.has(dir)) continue - set.add(dir) - next.dirs.push(dir + "/") - } + next.dirs = Array.from(dirs).toSorted() + } else { + const seen = new Set<string>() + 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 - } finally { - task = undefined } - })() - return task - } + }) - const getFiles = async () => { - void kick() - return cache - } + cache = next + }) - const init = Effect.fn("FileService.init")(function* () { - yield* Effect.promise(() => kick()) + const getFiles = () => cache + + const scope = yield* Scope.Scope + let fiber: Fiber.Fiber<void> | 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("FileService.status")(function* () { + const status = Effect.fn("File.status")(function* () { if (instance.project.vcs !== "git") return [] return yield* Effect.promise(async () => { @@ -461,14 +445,13 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser }) ).text() - const changedFiles: File.Info[] = [] + const changed: File.Info[] = [] if (diffOutput.trim()) { - const lines = diffOutput.trim().split("\n") - for (const line of lines) { - const [added, removed, filepath] = line.split("\t") - changedFiles.push({ - path: filepath, + 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", @@ -494,14 +477,12 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser ).text() if (untrackedOutput.trim()) { - const untrackedFiles = untrackedOutput.trim().split("\n") - for (const filepath of untrackedFiles) { + for (const file of untrackedOutput.trim().split("\n")) { try { - const content = await Filesystem.readText(path.join(instance.directory, filepath)) - const lines = content.split("\n").length - changedFiles.push({ - path: filepath, - added: lines, + const content = await Filesystem.readText(path.join(instance.directory, file)) + changed.push({ + path: file, + added: content.split("\n").length, removed: 0, status: "added", }) @@ -511,7 +492,6 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser } } - // Get deleted files const deletedOutput = ( await git( [ @@ -531,50 +511,51 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser ).text() if (deletedOutput.trim()) { - const deletedFiles = deletedOutput.trim().split("\n") - for (const filepath of deletedFiles) { - changedFiles.push({ - path: filepath, + for (const file of deletedOutput.trim().split("\n")) { + changed.push({ + path: file, added: 0, - removed: 0, // Could get original line count but would require another git command + removed: 0, status: "deleted", }) } } - return changedFiles.map((x) => { - const full = path.isAbsolute(x.path) ? x.path : path.join(instance.directory, x.path) + return changed.map((item) => { + const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path) return { - ...x, + ...item, path: path.relative(instance.directory, full), } }) }) }) - const read = Effect.fn("FileService.read")(function* (file: string) { + const read = Effect.fn("File.read")(function* (file: string) { return yield* Effect.promise(async (): Promise<File.Content> => { 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`) + throw new Error("Access denied: path escapes project directory") } - // Fast path: check extension before any filesystem operations if (isImageByExtension(file)) { if (await Filesystem.exists(full)) { const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([])) - const content = buffer.toString("base64") - const mimeType = getImageMimeType(file) - return { type: "text", content, mimeType, encoding: "base64" } + return { + type: "text", + content: buffer.toString("base64"), + mimeType: getImageMimeType(file), + encoding: "base64", + } } return { type: "text", content: "" } } - const text = isTextByExtension(file) || isTextByName(file) + const knownText = isTextByExtension(file) || isTextByName(file) - if (isBinaryByExtension(file) && !text) { + if (isBinaryByExtension(file) && !knownText) { return { type: "binary", content: "" } } @@ -583,7 +564,7 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser } const mimeType = Filesystem.mimeType(full) - const encode = text ? false : shouldEncode(mimeType) + const encode = knownText ? false : shouldEncode(mimeType) if (encode && !isImage(mimeType)) { return { type: "binary", content: "", mimeType } @@ -591,8 +572,12 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser if (encode) { const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([])) - const content = buffer.toString("base64") - return { type: "text", content, mimeType, encoding: "base64" } + return { + type: "text", + content: buffer.toString("base64"), + mimeType, + encoding: "base64", + } } const content = (await Filesystem.readText(full).catch(() => "")).trim() @@ -603,7 +588,9 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser ).text() if (!diff.trim()) { diff = ( - await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: instance.directory }) + await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { + cwd: instance.directory, + }) ).text() } if (diff.trim()) { @@ -612,64 +599,64 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser context: Infinity, ignoreWhitespace: true, }) - const diff = formatPatch(patch) - return { type: "text", content, patch, diff } + return { + type: "text", + content, + patch, + diff: formatPatch(patch), + } } } + return { type: "text", content } }) }) - const list = Effect.fn("FileService.list")(function* (dir?: string) { + 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 gitignorePath = path.join(instance.project.worktree, ".gitignore") - if (await Filesystem.exists(gitignorePath)) { - ig.add(await Filesystem.readText(gitignorePath)) + const gitignore = path.join(instance.project.worktree, ".gitignore") + if (await Filesystem.exists(gitignore)) { + ig.add(await Filesystem.readText(gitignore)) } - const ignorePath = path.join(instance.project.worktree, ".ignore") - if (await Filesystem.exists(ignorePath)) { - ig.add(await Filesystem.readText(ignorePath)) + 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 + const resolved = dir ? path.join(instance.directory, dir) : instance.directory if (!Instance.containsPath(resolved)) { - throw new Error(`Access denied: path escapes project directory`) + 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(() => [])) { + for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) { if (exclude.includes(entry.name)) continue - const fullPath = path.join(resolved, entry.name) - const relativePath = path.relative(instance.directory, fullPath) + 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: relativePath, - absolute: fullPath, + path: file, + absolute, type, - ignored: ignored(type === "directory" ? relativePath + "/" : relativePath), + ignored: ignored(type === "directory" ? file + "/" : file), }) } + return nodes.sort((a, b) => { - if (a.type !== b.type) { - return a.type === "directory" ? -1 : 1 - } + if (a.type !== b.type) return a.type === "directory" ? -1 : 1 return a.name.localeCompare(b.name) }) }) }) - const search = Effect.fn("FileService.search")(function* (input: { + const search = Effect.fn("File.search")(function* (input: { query: string limit?: number dirs?: boolean @@ -681,35 +668,20 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser const kind = input.type ?? (input.dirs === false ? "file" : "all") log.info("search", { query, kind }) - const result = await getFiles() - - const hidden = (item: string) => { - const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "") - return normalized.split("/").some((p) => p.startsWith(".") && p.length > 1) - } + const result = getFiles() const preferHidden = query.startsWith(".") || query.includes("/.") - const sortHiddenLast = (items: string[]) => { - if (preferHidden) return items - const visible: string[] = [] - const hiddenItems: string[] = [] - for (const item of items) { - const isHidden = hidden(item) - if (isHidden) hiddenItems.push(item) - if (!isHidden) visible.push(item) - } - return [...visible, ...hiddenItems] - } + if (!query) { if (kind === "file") return result.files.slice(0, limit) - return sortHiddenLast(result.dirs.toSorted()).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((r) => r.target) - const output = kind === "directory" ? sortHiddenLast(sorted).slice(0, limit) : sorted + 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 @@ -717,8 +689,7 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser }) log.info("init") - - return FileService.of({ init, status, read, list, search }) + return Service.of({ init, status, read, list, search }) }), ) } diff --git a/packages/opencode/src/file/time.ts b/packages/opencode/src/file/time.ts index c956cdfdb..3d94bc122 100644 --- a/packages/opencode/src/file/time.ts +++ b/packages/opencode/src/file/time.ts @@ -1,115 +1,110 @@ -import { Log } from "../util/log" -import { Flag } from "@/flag/flag" -import { Filesystem } from "../util/filesystem" -import { Effect, Layer, ServiceMap, Semaphore } from "effect" +import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect" import { runPromiseInstance } from "@/effect/runtime" +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" }) -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, Map<string, Stamp>>, sessionID: SessionID) => { + const value = reads.get(sessionID) + if (value) return value + + const next = new Map<string, Stamp>() + reads.set(sessionID, next) + return next + } -export namespace FileTimeService { - export interface Service { + export interface Interface { readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void> readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined> readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void> readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T> } -} - -type Stamp = { - readonly read: Date - readonly mtime: number | undefined - readonly ctime: number | undefined - readonly size: number | undefined -} - -function stamp(file: string): Stamp { - const stat = Filesystem.stat(file) - const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size - return { - read: new Date(), - mtime: stat?.mtime?.getTime(), - ctime: stat?.ctime?.getTime(), - size, - } -} -function session(reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) { - let value = reads.get(sessionID) - if (!value) { - value = new Map<string, Stamp>() - reads.set(sessionID, value) - } - return value -} + export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {} -export class FileTimeService extends ServiceMap.Service<FileTimeService, FileTimeService.Service>()( - "@opencode/FileTime", -) { - static readonly layer = Layer.effect( - FileTimeService, + export const layer = Layer.effect( + Service, Effect.gen(function* () { const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK const reads = new Map<SessionID, Map<string, Stamp>>() const locks = new Map<string, Semaphore.Semaphore>() - function getLock(filepath: string) { - let lock = locks.get(filepath) - if (!lock) { - lock = Semaphore.makeUnsafe(1) - locks.set(filepath, lock) - } - return lock + const getLock = (filepath: string) => { + const lock = locks.get(filepath) + if (lock) return lock + + const next = Semaphore.makeUnsafe(1) + locks.set(filepath, next) + return next } - return FileTimeService.of({ - read: Effect.fn("FileTimeService.read")(function* (sessionID: SessionID, file: string) { - log.info("read", { sessionID, file }) - session(reads, sessionID).set(file, stamp(file)) - }), - - get: Effect.fn("FileTimeService.get")(function* (sessionID: SessionID, file: string) { - return reads.get(sessionID)?.get(file)?.read - }), - - assert: Effect.fn("FileTimeService.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 = stamp(filepath) - const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size - - if (changed) { - 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.`, - ) - } - }), - - withLock: Effect.fn("FileTimeService.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) { - const lock = getLock(filepath) - return yield* Effect.promise(fn).pipe(lock.withPermits(1)) - }), + 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* <T>(filepath: string, fn: () => Promise<T>) { + return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1)) }) + + return Service.of({ read, get, assert, withLock }) }), ) -} -export namespace FileTime { export function read(sessionID: SessionID, file: string) { - return runPromiseInstance(FileTimeService.use((s) => s.read(sessionID, file))) + return runPromiseInstance(Service.use((s) => s.read(sessionID, file))) } export function get(sessionID: SessionID, file: string) { - return runPromiseInstance(FileTimeService.use((s) => s.get(sessionID, file))) + return runPromiseInstance(Service.use((s) => s.get(sessionID, file))) } export async function assert(sessionID: SessionID, filepath: string) { - return runPromiseInstance(FileTimeService.use((s) => s.assert(sessionID, filepath))) + return runPromiseInstance(Service.use((s) => s.assert(sessionID, filepath))) } export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> { - return runPromiseInstance(FileTimeService.use((s) => s.withLock(filepath, fn))) + return runPromiseInstance(Service.use((s) => s.withLock(filepath, fn))) } } diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 16ee8f27c..ad683303c 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -1,89 +1,76 @@ -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" -import { InstanceContext } from "@/effect/instance-context" -import { Instance } from "@/project/instance" -import z from "zod" -import { Log } from "../util/log" -import { FileIgnore } from "./ignore" -import { Config } from "../config/config" -import path from "path" +import { Cause, Effect, Layer, ServiceMap } from "effect" // @ts-ignore import { createWrapper } from "@parcel/watcher/wrapper" -import { lazy } from "@/util/lazy" import type ParcelWatcher from "@parcel/watcher" import { readdir } from "fs/promises" +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 { Flag } from "@/flag/flag" +import { Instance } from "@/project/instance" import { git } from "@/util/git" +import { lazy } from "@/util/lazy" +import { Config } from "../config/config" +import { FileIgnore } from "./ignore" import { Protected } from "./protected" -import { Flag } from "@/flag/flag" -import { Cause, Effect, Layer, ServiceMap } from "effect" - -const SUBSCRIBE_TIMEOUT_MS = 10_000 +import { Log } from "../util/log" declare const OPENCODE_LIBC: string | undefined -const log = Log.create({ service: "file.watcher" }) - -const event = { - Updated: BusEvent.define( - "file.watcher.updated", - z.object({ - file: z.string(), - event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]), - }), - ), -} - -const watcher = lazy((): typeof import("@parcel/watcher") | undefined => { - try { - const binding = require( - `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`, - ) - return createWrapper(binding) as typeof import("@parcel/watcher") - } catch (error) { - log.error("failed to load watcher binding", { error }) - return +export namespace FileWatcher { + const log = Log.create({ service: "file.watcher" }) + const SUBSCRIBE_TIMEOUT_MS = 10_000 + + export const Event = { + Updated: BusEvent.define( + "file.watcher.updated", + z.object({ + file: z.string(), + event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]), + }), + ), } -}) -function getBackend() { - if (process.platform === "win32") return "windows" - if (process.platform === "darwin") return "fs-events" - if (process.platform === "linux") return "inotify" -} + const watcher = lazy((): typeof import("@parcel/watcher") | undefined => { + try { + const binding = require( + `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`, + ) + return createWrapper(binding) as typeof import("@parcel/watcher") + } catch (error) { + log.error("failed to load watcher binding", { error }) + return + } + }) + + function getBackend() { + if (process.platform === "win32") return "windows" + if (process.platform === "darwin") return "fs-events" + if (process.platform === "linux") return "inotify" + } -export namespace FileWatcher { - export const Event = event - /** Whether the native @parcel/watcher binding is available on this platform. */ export const hasNativeBinding = () => !!watcher() -} - -const init = Effect.fn("FileWatcherService.init")(function* () {}) -export namespace FileWatcherService { - export interface Service { - readonly init: () => Effect.Effect<void> - } -} + export class Service extends ServiceMap.Service<Service, {}>()("@opencode/FileWatcher") {} -export class FileWatcherService extends ServiceMap.Service<FileWatcherService, FileWatcherService.Service>()( - "@opencode/FileWatcher", -) { - static readonly layer = Layer.effect( - FileWatcherService, + export const layer = Layer.effect( + Service, Effect.gen(function* () { const instance = yield* InstanceContext - if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return FileWatcherService.of({ init }) + 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 FileWatcherService.of({ init }) + return Service.of({}) } const w = watcher() - if (!w) return FileWatcherService.of({ init }) + if (!w) return Service.of({}) log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend }) @@ -93,9 +80,9 @@ export class FileWatcherService extends ServiceMap.Service<FileWatcherService, F 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" }) + 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" }) } }) @@ -108,7 +95,6 @@ export class FileWatcherService extends ServiceMap.Service<FileWatcherService, F Effect.timeout(SUBSCRIBE_TIMEOUT_MS), Effect.catchCause((cause) => { log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) }) - // Clean up a subscription that resolves after timeout pending.then((s) => s.unsubscribe()).catch(() => {}) return Effect.void }), @@ -137,11 +123,11 @@ export class FileWatcherService extends ServiceMap.Service<FileWatcherService, F } } - return FileWatcherService.of({ init }) + return Service.of({}) }).pipe( Effect.catchCause((cause) => { log.error("failed to init watcher service", { cause: Cause.pretty(cause) }) - return Effect.succeed(FileWatcherService.of({ init })) + return Effect.succeed(Service.of({})) }), ), ) diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index cb71fc363..6da8caa08 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -1,21 +1,20 @@ -import { Bus } from "../bus" -import { File } from "../file" -import { Log } from "../util/log" +import { Effect, Layer, ServiceMap } from "effect" +import { runPromiseInstance } from "@/effect/runtime" +import { InstanceContext } from "@/effect/instance-context" import path from "path" +import { mergeDeep } from "remeda" import z from "zod" - -import * as Formatter from "./formatter" +import { Bus } from "../bus" import { Config } from "../config/config" -import { mergeDeep } from "remeda" +import { File } from "../file" import { Instance } from "../project/instance" import { Process } from "../util/process" -import { InstanceContext } from "@/effect/instance-context" -import { Effect, Layer, ServiceMap } from "effect" -import { runPromiseInstance } from "@/effect/runtime" - -const log = Log.create({ service: "format" }) +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(), @@ -27,25 +26,14 @@ export namespace Format { }) export type Status = z.infer<typeof Status> - export async function init() { - return runPromiseInstance(FormatService.use((s) => s.init())) - } - - export async function status() { - return runPromiseInstance(FormatService.use((s) => s.status())) + export interface Interface { + readonly status: () => Effect.Effect<Status[]> } -} -export namespace FormatService { - export interface Service { - readonly init: () => Effect.Effect<void> - readonly status: () => Effect.Effect<Format.Status[]> - } -} + export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {} -export class FormatService extends ServiceMap.Service<FormatService, FormatService.Service>()("@opencode/Format") { - static readonly layer = Layer.effect( - FormatService, + export const layer = Layer.effect( + Service, Effect.gen(function* () { const instance = yield* InstanceContext @@ -63,17 +51,19 @@ export class FormatService extends ServiceMap.Service<FormatService, FormatServi delete formatters[name] continue } - const result = mergeDeep(formatters[name] ?? {}, { + const info = mergeDeep(formatters[name] ?? {}, { command: [], extensions: [], ...item, - }) as Formatter.Info + }) - if (result.command.length === 0) continue + if (info.command.length === 0) continue - result.enabled = async () => true - result.name = name - formatters[name] = result + formatters[name] = { + ...info, + name, + enabled: async () => true, + } } } else { log.info("all formatters are disabled") @@ -100,50 +90,52 @@ export class FormatService extends ServiceMap.Service<FormatService, FormatServi return result } - const unsubscribe = 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, - }) - } - } - }), + 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), ) - - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)) log.info("init") - const init = Effect.fn("FormatService.init")(function* () {}) - - const status = Effect.fn("FormatService.status")(function* () { - const result: Format.Status[] = [] + 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({ @@ -155,7 +147,11 @@ export class FormatService extends ServiceMap.Service<FormatService, FormatServi return result }) - return FormatService.of({ init, status }) + return Service.of({ status }) }), ) + + export async function status() { + return runPromiseInstance(Service.use((s) => s.status())) + } } diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index e7eb0eea6..93a8c49b6 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -1,11 +1,251 @@ import { runPromiseInstance } from "@/effect/runtime" +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 { fn } from "@/util/fn" +import { Log } from "@/util/log" import { Wildcard } from "@/util/wildcard" +import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" import os from "os" -import { PermissionEffect as S } from "./service" +import z from "zod" +import { PermissionID } from "./schema" export namespace PermissionNext { + const log = Log.create({ service: "permission" }) + + export const Action = z.enum(["allow", "deny", "ask"]).meta({ + ref: "PermissionAction", + }) + export type Action = z.infer<typeof Action> + + export const Rule = z + .object({ + permission: z.string(), + pattern: z.string(), + action: Action, + }) + .meta({ + ref: "PermissionRule", + }) + export type Rule = z.infer<typeof Rule> + + export const Ruleset = Rule.array().meta({ + ref: "PermissionRuleset", + }) + export type Ruleset = z.infer<typeof Ruleset> + + 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<typeof Request> + + export const Reply = z.enum(["once", "always", "reject"]) + export type Reply = z.infer<typeof Reply> + + 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<RejectedError>()("PermissionRejectedError", {}) { + override get message() { + return "The user rejected permission to use this specific tool call." + } + } + + export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("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<DeniedError>()("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<typeof AskInput>) => Effect.Effect<void, Error> + readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void> + readonly list: () => Effect.Effect<Request[]> + } + + interface PendingEntry { + info: Request + deferred: Deferred.Deferred<void, RejectedError | CorrectedError> + } + + export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { + const rules = rulesets.flat() + log.info("evaluate", { permission, pattern, ruleset: rules }) + const match = rules.findLast( + (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern), + ) + return match ?? { action: "ask", permission, pattern: "*" } + } + + export class Service extends ServiceMap.Service<Service, Interface>()("@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<PermissionID, PendingEntry>() + const approved: Ruleset = row?.data ?? [] + + const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) { + 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<void, RejectedError | CorrectedError>() + 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<typeof ReplyInput>) { + 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 }) + }), + ) + function expand(pattern: string): string { if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1) if (pattern === "~") return os.homedir() @@ -14,32 +254,11 @@ export namespace PermissionNext { return pattern } - export const Action = S.Action - export type Action = S.Action - export const Rule = S.Rule - export type Rule = S.Rule - export const Ruleset = S.Ruleset - export type Ruleset = S.Ruleset - export const Request = S.Request - export type Request = S.Request - export const Reply = S.Reply - export type Reply = S.Reply - export const Approval = S.Approval - export const Event = S.Event - export const Service = S.Service - export const RejectedError = S.RejectedError - export const CorrectedError = S.CorrectedError - export const DeniedError = S.DeniedError - 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: "*", - }) + ruleset.push({ permission: key, action: value, pattern: "*" }) continue } ruleset.push( @@ -53,18 +272,12 @@ export namespace PermissionNext { return rulesets.flat() } - export const ask = fn(S.AskInput, async (input) => runPromiseInstance(S.Service.use((service) => service.ask(input)))) + export const ask = fn(AskInput, async (input) => runPromiseInstance(Service.use((svc) => svc.ask(input)))) - export const reply = fn(S.ReplyInput, async (input) => - runPromiseInstance(S.Service.use((service) => service.reply(input))), - ) + export const reply = fn(ReplyInput, async (input) => runPromiseInstance(Service.use((svc) => svc.reply(input)))) export async function list() { - return runPromiseInstance(S.Service.use((service) => service.list())) - } - - export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { - return S.evaluate(permission, pattern, ...rulesets) + return runPromiseInstance(Service.use((svc) => svc.list())) } const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"] diff --git a/packages/opencode/src/permission/service.ts b/packages/opencode/src/permission/service.ts deleted file mode 100644 index 4335aa4cd..000000000 --- a/packages/opencode/src/permission/service.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { Bus } from "@/bus" -import { BusEvent } from "@/bus/bus-event" -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 z from "zod" -import { PermissionID } from "./schema" - -export namespace PermissionEffect { - const log = Log.create({ service: "permission" }) - - export const Action = z.enum(["allow", "deny", "ask"]).meta({ - ref: "PermissionAction", - }) - export type Action = z.infer<typeof Action> - - export const Rule = z - .object({ - permission: z.string(), - pattern: z.string(), - action: Action, - }) - .meta({ - ref: "PermissionRule", - }) - export type Rule = z.infer<typeof Rule> - - export const Ruleset = Rule.array().meta({ - ref: "PermissionRuleset", - }) - export type Ruleset = z.infer<typeof Ruleset> - - 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<typeof Request> - - export const Reply = z.enum(["once", "always", "reject"]) - export type Reply = z.infer<typeof Reply> - - 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<RejectedError>()("PermissionRejectedError", {}) { - override get message() { - return "The user rejected permission to use this specific tool call." - } - } - - export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("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<DeniedError>()("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 Api { - readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error> - readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void> - readonly list: () => Effect.Effect<Request[]> - } - - interface PendingEntry { - info: Request - deferred: Deferred.Deferred<void, RejectedError | CorrectedError> - } - - export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { - const rules = rulesets.flat() - log.info("evaluate", { permission, pattern, ruleset: rules }) - const match = rules.findLast( - (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern), - ) - return match ?? { action: "ask", permission, pattern: "*" } - } - - export class Service extends ServiceMap.Service<Service, Api>()("@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<PermissionID, PendingEntry>() - const approved: Ruleset = row?.data ?? [] - - const ask = Effect.fn("PermissionService.ask")(function* (input: z.infer<typeof AskInput>) { - 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<void, RejectedError | CorrectedError>() - 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("PermissionService.reply")(function* (input: z.infer<typeof ReplyInput>) { - 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("PermissionService.list")(function* () { - return Array.from(pending.values(), (item) => item.info) - }) - - return Service.of({ ask, reply, list }) - }), - ) -} diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 40a4ce9cc..86403f3da 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -1,30 +1,23 @@ import { Plugin } from "../plugin" -import { Format } from "../format" import { LSP } from "../lsp" -import { FileWatcherService } from "../file/watcher" import { File } from "../file" import { Project } from "./project" import { Bus } from "../bus" import { Command } from "../command" import { Instance } from "./instance" -import { VcsService } from "./vcs" import { Log } from "@/util/log" import { ShareNext } from "@/share/share-next" -import { runPromiseInstance } from "@/effect/runtime" export async function InstanceBootstrap() { Log.Default.info("bootstrapping", { directory: Instance.directory }) await Plugin.init() ShareNext.init() - await Format.init() await LSP.init() - await runPromiseInstance(FileWatcherService.use((service) => service.init())) File.init() - await runPromiseInstance(VcsService.use((s) => s.init())) Bus.subscribe(Command.Event.Executed, async (payload) => { if (payload.properties.name === Command.Default.INIT) { - await Project.setInitialized(Instance.project.id) + Project.setInitialized(Instance.project.id) } }) } diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 4d1f7b766..9e85571c4 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -1,16 +1,16 @@ -import { BusEvent } from "@/bus/bus-event" +import { Effect, Layer, ServiceMap } from "effect" import { Bus } from "@/bus" -import z from "zod" -import { Log } from "@/util/log" -import { Instance } from "./instance" +import { BusEvent } from "@/bus/bus-event" import { InstanceContext } from "@/effect/instance-context" import { FileWatcher } from "@/file/watcher" +import { Log } from "@/util/log" import { git } from "@/util/git" -import { Effect, Layer, ServiceMap } from "effect" - -const log = Log.create({ service: "vcs" }) +import { Instance } from "./instance" +import z from "zod" export namespace Vcs { + const log = Log.create({ service: "vcs" }) + export const Event = { BranchUpdated: BusEvent.define( "vcs.branch.updated", @@ -28,24 +28,21 @@ export namespace Vcs { ref: "VcsInfo", }) export type Info = z.infer<typeof Info> -} -export namespace VcsService { - export interface Service { - readonly init: () => Effect.Effect<void> + export interface Interface { readonly branch: () => Effect.Effect<string | undefined> } -} -export class VcsService extends ServiceMap.Service<VcsService, VcsService.Service>()("@opencode/Vcs") { - static readonly layer = Layer.effect( - VcsService, + export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {} + + export const layer = Layer.effect( + Service, Effect.gen(function* () { const instance = yield* InstanceContext - let current: string | undefined + let currentBranch: string | undefined if (instance.project.vcs === "git") { - const currentBranch = async () => { + const getCurrentBranch = async () => { const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: instance.project.worktree, }) @@ -54,29 +51,31 @@ export class VcsService extends ServiceMap.Service<VcsService, VcsService.Servic return text || undefined } - current = yield* Effect.promise(() => currentBranch()) - log.info("initialized", { branch: current }) + currentBranch = yield* Effect.promise(() => getCurrentBranch()) + log.info("initialized", { branch: currentBranch }) - const unsubscribe = Bus.subscribe( - FileWatcher.Event.Updated, - Instance.bind(async (evt) => { - if (!evt.properties.file.endsWith("HEAD")) return - const next = await currentBranch() - if (next !== current) { - log.info("branch changed", { from: current, to: next }) - current = next - Bus.publish(Vcs.Event.BranchUpdated, { branch: next }) - } - }), + 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), ) - - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)) } - return VcsService.of({ - init: Effect.fn("VcsService.init")(function* () {}), - branch: Effect.fn("VcsService.branch")(function* () { - return current + return Service.of({ + branch: Effect.fn("Vcs.branch")(function* () { + return currentBranch }), }) }), diff --git a/packages/opencode/src/provider/auth-service.ts b/packages/opencode/src/provider/auth-service.ts deleted file mode 100644 index 900b31d10..000000000 --- a/packages/opencode/src/provider/auth-service.ts +++ /dev/null @@ -1,230 +0,0 @@ -import type { AuthOuathResult } from "@opencode-ai/plugin" -import { NamedError } from "@opencode-ai/util/error" -import * as Auth from "@/auth/service" -import { ProviderID } from "./schema" -import { Effect, Layer, Record, ServiceMap, Struct } from "effect" -import { filter, fromEntries, map, pipe } from "remeda" -import z from "zod" - -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<typeof Method> - -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<typeof Authorization> - -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 ProviderAuthError = - | Auth.AuthServiceError - | InstanceType<typeof OauthMissing> - | InstanceType<typeof OauthCodeMissing> - | InstanceType<typeof OauthCallbackFailed> - | InstanceType<typeof ValidationFailed> - -export namespace ProviderAuthService { - export interface Service { - readonly methods: () => Effect.Effect<Record<string, Method[]>> - readonly authorize: (input: { - providerID: ProviderID - method: number - inputs?: Record<string, string> - }) => Effect.Effect<Authorization | undefined, ProviderAuthError> - readonly callback: (input: { - providerID: ProviderID - method: number - code?: string - }) => Effect.Effect<void, ProviderAuthError> - } -} - -export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService, ProviderAuthService.Service>()( - "@opencode/ProviderAuth", -) { - static readonly layer = Layer.effect( - ProviderAuthService, - Effect.gen(function* () { - const auth = yield* Auth.AuthService - const hooks = yield* Effect.promise(async () => { - const mod = await import("../plugin") - return pipe( - await mod.Plugin.list(), - filter((x) => x.auth?.provider !== undefined), - map((x) => [x.auth!.provider, x.auth!] as const), - fromEntries(), - ) - }) - const pending = new Map<ProviderID, AuthOuathResult>() - - const methods = Effect.fn("ProviderAuthService.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("ProviderAuthService.authorize")(function* (input: { - providerID: ProviderID - method: number - inputs?: Record<string, string> - }) { - 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("ProviderAuthService.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 ProviderAuthService.of({ - methods, - authorize, - callback, - }) - }), - ) - - static readonly defaultLayer = ProviderAuthService.layer.pipe(Layer.provide(Auth.AuthService.defaultLayer)) -} diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 912b12076..5204b5fb8 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -1,20 +1,223 @@ -import z from "zod" - +import type { AuthOuathResult } from "@opencode-ai/plugin" +import { NamedError } from "@opencode-ai/util/error" +import * as Auth from "@/auth/effect" import { runPromiseInstance } from "@/effect/runtime" import { fn } from "@/util/fn" -import * as S from "./auth-service" 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 = 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<typeof Method> - export async function methods() { - return runPromiseInstance(S.ProviderAuthService.use((service) => service.methods())) + export const Authorization = z + .object({ + url: z.string(), + method: z.union([z.literal("auto"), z.literal("code")]), + instructions: z.string(), + }) + .meta({ + ref: "ProviderAuthAuthorization", + }) + export type Authorization = z.infer<typeof Authorization> + + 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<typeof OauthMissing> + | InstanceType<typeof OauthCodeMissing> + | InstanceType<typeof OauthCallbackFailed> + | InstanceType<typeof ValidationFailed> + + export interface Interface { + readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>> + readonly authorize: (input: { + providerID: ProviderID + method: number + inputs?: Record<string, string> + }) => Effect.Effect<Authorization | undefined, Error> + readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect<void, Error> } - export const Authorization = S.Authorization - export type Authorization = S.Authorization + export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const auth = yield* Auth.AuthEffect.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<ProviderID, AuthOuathResult>() + + 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<string, string> + }) { + 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 }) + }), + ) + + export const defaultLayer = layer.pipe(Layer.provide(Auth.AuthEffect.layer)) + + export async function methods() { + return runPromiseInstance(Service.use((svc) => svc.methods())) + } export const authorize = fn( z.object({ @@ -22,8 +225,7 @@ export namespace ProviderAuth { method: z.number(), inputs: z.record(z.string(), z.string()).optional(), }), - async (input): Promise<Authorization | undefined> => - runPromiseInstance(S.ProviderAuthService.use((service) => service.authorize(input))), + async (input): Promise<Authorization | undefined> => runPromiseInstance(Service.use((svc) => svc.authorize(input))), ) export const callback = fn( @@ -32,11 +234,6 @@ export namespace ProviderAuth { method: z.number(), code: z.string().optional(), }), - async (input) => runPromiseInstance(S.ProviderAuthService.use((service) => service.callback(input))), + async (input) => runPromiseInstance(Service.use((svc) => svc.callback(input))), ) - - export import OauthMissing = S.OauthMissing - export import OauthCodeMissing = S.OauthCodeMissing - export import OauthCallbackFailed = S.OauthCallbackFailed - export import ValidationFailed = S.ValidationFailed } diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 7fffc0c87..551c51399 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -1,39 +1,193 @@ +import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" import { runPromiseInstance } from "@/effect/runtime" -import * as S from "./service" -import type { QuestionID } from "./schema" -import type { SessionID, MessageID } from "@/session/schema" +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 { - export const Option = S.Option - export type Option = S.Option - export const Info = S.Info - export type Info = S.Info - export const Request = S.Request - export type Request = S.Request - export const Answer = S.Answer - export type Answer = S.Answer - export const Reply = S.Reply - export type Reply = S.Reply - export const Event = S.Event - export const RejectedError = S.RejectedError + // 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<typeof Option> + + 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<typeof Info> + + 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<typeof Request> + + export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" }) + export type Answer = z.infer<typeof Answer> + + 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<typeof Reply> + + 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<RejectedError>()("QuestionRejectedError", {}) { + override get message() { + return "The user dismissed this question" + } + } + + interface PendingEntry { + info: Request + deferred: Deferred.Deferred<Answer[], RejectedError> + } + + // Service + + export interface Interface { + readonly ask: (input: { + sessionID: SessionID + questions: Info[] + tool?: { messageID: MessageID; callID: string } + }) => Effect.Effect<Answer[], RejectedError> + readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void> + readonly reject: (requestID: QuestionID) => Effect.Effect<void> + readonly list: () => Effect.Effect<Request[]> + } + + export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Question") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const pending = new Map<QuestionID, PendingEntry>() + + 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<Answer[], RejectedError>() + 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 }) + }), + ) export async function ask(input: { sessionID: SessionID questions: Info[] tool?: { messageID: MessageID; callID: string } }): Promise<Answer[]> { - return runPromiseInstance(S.QuestionService.use((service) => service.ask(input))) + return runPromiseInstance(Service.use((svc) => svc.ask(input))) } export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> { - return runPromiseInstance(S.QuestionService.use((service) => service.reply(input))) + return runPromiseInstance(Service.use((svc) => svc.reply(input))) } export async function reject(requestID: QuestionID): Promise<void> { - return runPromiseInstance(S.QuestionService.use((service) => service.reject(requestID))) + return runPromiseInstance(Service.use((svc) => svc.reject(requestID))) } export async function list(): Promise<Request[]> { - return runPromiseInstance(S.QuestionService.use((service) => service.list())) + return runPromiseInstance(Service.use((svc) => svc.list())) } } diff --git a/packages/opencode/src/question/service.ts b/packages/opencode/src/question/service.ts deleted file mode 100644 index 3df8286e6..000000000 --- a/packages/opencode/src/question/service.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" -import { Bus } from "@/bus" -import { BusEvent } from "@/bus/bus-event" -import { SessionID, MessageID } from "@/session/schema" -import { Log } from "@/util/log" -import z from "zod" -import { QuestionID } from "./schema" - -const log = Log.create({ service: "question" }) - -// --- Zod schemas (re-exported by facade) --- - -export const Option = z - .object({ - label: z.string().describe("Display text (1-5 words, concise)"), - description: z.string().describe("Explanation of choice"), - }) - .meta({ ref: "QuestionOption" }) -export type Option = z.infer<typeof Option> - -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<typeof Info> - -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<typeof Request> - -export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" }) -export type Answer = z.infer<typeof Answer> - -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<typeof Reply> - -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<RejectedError>()("QuestionRejectedError", {}) { - override get message() { - return "The user dismissed this question" - } -} - -// --- Effect service --- - -interface PendingEntry { - info: Request - deferred: Deferred.Deferred<Answer[], RejectedError> -} - -export namespace QuestionService { - export interface Service { - readonly ask: (input: { - sessionID: SessionID - questions: Info[] - tool?: { messageID: MessageID; callID: string } - }) => Effect.Effect<Answer[], RejectedError> - readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void> - readonly reject: (requestID: QuestionID) => Effect.Effect<void> - readonly list: () => Effect.Effect<Request[]> - } -} - -export class QuestionService extends ServiceMap.Service<QuestionService, QuestionService.Service>()( - "@opencode/Question", -) { - static readonly layer = Layer.effect( - QuestionService, - Effect.gen(function* () { - const pending = new Map<QuestionID, PendingEntry>() - - const ask = Effect.fn("QuestionService.ask")(function* (input: { - sessionID: SessionID - questions: Info[] - tool?: { messageID: MessageID; callID: string } - }) { - const id = QuestionID.ascending() - log.info("asking", { id, questions: input.questions.length }) - - const deferred = yield* Deferred.make<Answer[], RejectedError>() - const info: Request = { - id, - sessionID: input.sessionID, - questions: input.questions, - tool: input.tool, - } - pending.set(id, { info, deferred }) - Bus.publish(Event.Asked, info) - - return yield* Effect.ensuring( - Deferred.await(deferred), - Effect.sync(() => { - pending.delete(id) - }), - ) - }) - - const reply = Effect.fn("QuestionService.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) { - const existing = pending.get(input.requestID) - if (!existing) { - log.warn("reply for unknown request", { requestID: input.requestID }) - return - } - pending.delete(input.requestID) - log.info("replied", { requestID: input.requestID, answers: input.answers }) - Bus.publish(Event.Replied, { - sessionID: existing.info.sessionID, - requestID: existing.info.id, - answers: input.answers, - }) - yield* Deferred.succeed(existing.deferred, input.answers) - }) - - const reject = Effect.fn("QuestionService.reject")(function* (requestID: QuestionID) { - const existing = pending.get(requestID) - if (!existing) { - log.warn("reject for unknown request", { requestID }) - return - } - pending.delete(requestID) - log.info("rejected", { requestID }) - Bus.publish(Event.Rejected, { - sessionID: existing.info.sessionID, - requestID: existing.info.id, - }) - yield* Deferred.fail(existing.deferred, new RejectedError()) - }) - - const list = Effect.fn("QuestionService.list")(function* () { - return Array.from(pending.values(), (x) => x.info) - }) - - return QuestionService.of({ ask, reply, reject, list }) - }), - ) -} diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 1904706a1..c485654fd 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -14,7 +14,7 @@ import { LSP } from "../lsp" import { Format } from "../format" import { TuiRoutes } from "./routes/tui" import { Instance } from "../project/instance" -import { Vcs, VcsService } from "../project/vcs" +import { Vcs } from "../project/vcs" import { runPromiseInstance } from "@/effect/runtime" import { Agent } from "../agent/agent" import { Skill } from "../skill/skill" @@ -332,7 +332,7 @@ export namespace Server { }, }), async (c) => { - const branch = await runPromiseInstance(VcsService.use((s) => s.branch())) + const branch = await runPromiseInstance(Vcs.Service.use((s) => s.branch())) return c.json({ branch, }) diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts index fe03dccef..e5279503d 100644 --- a/packages/opencode/src/skill/discovery.ts +++ b/packages/opencode/src/skill/discovery.ts @@ -1,116 +1,117 @@ import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Effect, FileSystem, Layer, Path, Schema, ServiceMap } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { withTransientReadRetry } from "@/util/effect-http-client" import { Global } from "../global" import { Log } from "../util/log" -import { withTransientReadRetry } from "@/util/effect-http-client" -class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({ - name: Schema.String, - files: Schema.Array(Schema.String), -}) {} +export namespace Discovery { + const skillConcurrency = 4 + const fileConcurrency = 8 -class Index extends Schema.Class<Index>("Index")({ - skills: Schema.Array(IndexSkill), -}) {} + class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({ + name: Schema.String, + files: Schema.Array(Schema.String), + }) {} -const skillConcurrency = 4 -const fileConcurrency = 8 + class Index extends Schema.Class<Index>("Index")({ + skills: Schema.Array(IndexSkill), + }) {} -export namespace DiscoveryService { - export interface Service { + export interface Interface { readonly pull: (url: string) => Effect.Effect<string[]> } -} -export class DiscoveryService extends ServiceMap.Service<DiscoveryService, DiscoveryService.Service>()( - "@opencode/SkillDiscovery", -) { - static readonly layer = Layer.effect( - DiscoveryService, - Effect.gen(function* () { - const log = Log.create({ service: "skill-discovery" }) - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) - const cache = path.join(Global.Path.cache, "skills") - - const download = Effect.fn("DiscoveryService.download")(function* (url: string, dest: string) { - if (yield* fs.exists(dest).pipe(Effect.orDie)) return true - - return yield* HttpClientRequest.get(url).pipe( - http.execute, - Effect.flatMap((res) => res.arrayBuffer), - Effect.flatMap((body) => - fs - .makeDirectory(path.dirname(dest), { recursive: true }) - .pipe(Effect.flatMap(() => fs.writeFile(dest, new Uint8Array(body)))), - ), - Effect.as(true), - Effect.catch((err) => - Effect.sync(() => { - log.error("failed to download", { url, err }) + export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SkillDiscovery") {} + + export const layer: Layer.Layer<Service, never, FileSystem.FileSystem | Path.Path | HttpClient.HttpClient> = + Layer.effect( + Service, + Effect.gen(function* () { + const log = Log.create({ service: "skill-discovery" }) + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) + const cache = path.join(Global.Path.cache, "skills") + + const download = Effect.fn("Discovery.download")(function* (url: string, dest: string) { + if (yield* fs.exists(dest).pipe(Effect.orDie)) return true + + return yield* HttpClientRequest.get(url).pipe( + http.execute, + Effect.flatMap((res) => res.arrayBuffer), + Effect.flatMap((body) => + fs + .makeDirectory(path.dirname(dest), { recursive: true }) + .pipe(Effect.flatMap(() => fs.writeFile(dest, new Uint8Array(body)))), + ), + Effect.as(true), + Effect.catch((err) => + Effect.sync(() => { + log.error("failed to download", { url, err }) + return false + }), + ), + ) + }) + + const pull = Effect.fn("Discovery.pull")(function* (url: string) { + const base = url.endsWith("/") ? url : `${url}/` + const index = new URL("index.json", base).href + const host = base.slice(0, -1) + + log.info("fetching index", { url: index }) + + const data = yield* HttpClientRequest.get(index).pipe( + HttpClientRequest.acceptJson, + http.execute, + Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)), + Effect.catch((err) => + Effect.sync(() => { + log.error("failed to fetch index", { url: index, err }) + return null + }), + ), + ) + + if (!data) return [] + + const list = data.skills.filter((skill) => { + if (!skill.files.includes("SKILL.md")) { + log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name }) return false - }), - ), - ) - }) - - const pull: DiscoveryService.Service["pull"] = Effect.fn("DiscoveryService.pull")(function* (url: string) { - const base = url.endsWith("/") ? url : `${url}/` - const index = new URL("index.json", base).href - const host = base.slice(0, -1) - - log.info("fetching index", { url: index }) - - const data = yield* HttpClientRequest.get(index).pipe( - HttpClientRequest.acceptJson, - http.execute, - Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)), - Effect.catch((err) => - Effect.sync(() => { - log.error("failed to fetch index", { url: index, err }) - return null - }), - ), - ) - - if (!data) return [] - - const list = data.skills.filter((skill) => { - if (!skill.files.includes("SKILL.md")) { - log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name }) - return false - } - return true + } + return true + }) + + const dirs = yield* Effect.forEach( + list, + (skill) => + Effect.gen(function* () { + const root = path.join(cache, skill.name) + + yield* Effect.forEach( + skill.files, + (file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)), + { + concurrency: fileConcurrency, + }, + ) + + const md = path.join(root, "SKILL.md") + return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null + }), + { concurrency: skillConcurrency }, + ) + + return dirs.filter((dir): dir is string => dir !== null) }) - const dirs = yield* Effect.forEach( - list, - (skill) => - Effect.gen(function* () { - const root = path.join(cache, skill.name) - - yield* Effect.forEach( - skill.files, - (file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)), - { concurrency: fileConcurrency }, - ) - - const md = path.join(root, "SKILL.md") - return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null - }), - { concurrency: skillConcurrency }, - ) - - return dirs.filter((dir): dir is string => dir !== null) - }) - - return DiscoveryService.of({ pull }) - }), - ) + return Service.of({ pull }) + }), + ) - static readonly defaultLayer = DiscoveryService.layer.pipe( + export const defaultLayer: Layer.Layer<Service> = layer.pipe( Layer.provide(FetchHttpClient.layer), Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer), diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 79be9f779..d7aeb911f 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -1,34 +1,30 @@ -import z from "zod" -import path from "path" import os from "os" -import { Config } from "../config/config" -import { Instance } from "../project/instance" -import { NamedError } from "@opencode-ai/util/error" -import { ConfigMarkdown } from "../config/markdown" -import { Log } from "../util/log" -import { Global } from "@/global" -import { Filesystem } from "@/util/filesystem" -import { Flag } from "@/flag/flag" -import { Bus } from "@/bus" -import { DiscoveryService } from "./discovery" -import { Glob } from "../util/glob" +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 { PermissionNext } from "@/permission" +import { Bus } from "@/bus" import { InstanceContext } from "@/effect/instance-context" -import { Effect, Layer, ServiceMap } from "effect" import { runPromiseInstance } from "@/effect/runtime" - -const log = Log.create({ service: "skill" }) - -// External skill directories to search for (project-level and global) -// These follow the directory layout used by Claude Code and other agents. -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" +import { Flag } from "@/flag/flag" +import { Global } from "@/global" +import { PermissionNext } 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(), @@ -55,213 +51,205 @@ export namespace Skill { }), ) + type State = { + skills: Record<string, Info> + dirs: Set<string> + task?: Promise<void> + } + + type Cache = State & { + ensure: () => Promise<void> + } + + export interface Interface { + readonly get: (name: string) => Effect.Effect<Info | undefined> + readonly all: () => Effect.Effect<Info[]> + readonly dirs: () => Effect.Effect<string[]> + readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]> + } + + 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<string>(), + } + + 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<Service, Interface>()("@opencode/Skill") {} + + export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service> = 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) + if (!agent) return list + return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny") + }) + + return Service.of({ get, all, dirs, available }) + }), + ) + + export const defaultLayer: Layer.Layer<Service, never, InstanceContext> = layer.pipe( + Layer.provide(Discovery.defaultLayer), + ) + export async function get(name: string) { - return runPromiseInstance(SkillService.use((s) => s.get(name))) + return runPromiseInstance(Service.use((skill) => skill.get(name))) } export async function all() { - return runPromiseInstance(SkillService.use((s) => s.all())) + return runPromiseInstance(Service.use((skill) => skill.all())) } export async function dirs() { - return runPromiseInstance(SkillService.use((s) => s.dirs())) + return runPromiseInstance(Service.use((skill) => skill.dirs())) } export async function available(agent?: Agent.Info) { - return runPromiseInstance(SkillService.use((s) => s.available(agent))) + return runPromiseInstance(Service.use((skill) => skill.available(agent))) } export function fmt(list: Info[], opts: { verbose: boolean }) { - if (list.length === 0) { - return "No skills are currently available." - } + if (list.length === 0) return "No skills are currently available." + if (opts.verbose) { return [ "<available_skills>", ...list.flatMap((skill) => [ - ` <skill>`, + " <skill>", ` <name>${skill.name}</name>`, ` <description>${skill.description}</description>`, ` <location>${pathToFileURL(skill.location).href}</location>`, - ` </skill>`, + " </skill>", ]), "</available_skills>", ].join("\n") } - return ["## Available Skills", ...list.flatMap((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n") - } -} -export namespace SkillService { - export interface Service { - readonly get: (name: string) => Effect.Effect<Skill.Info | undefined> - readonly all: () => Effect.Effect<Skill.Info[]> - readonly dirs: () => Effect.Effect<string[]> - readonly available: (agent?: Agent.Info) => Effect.Effect<Skill.Info[]> + return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n") } } - -export class SkillService extends ServiceMap.Service<SkillService, SkillService.Service>()("@opencode/Skill") { - static readonly layer = Layer.effect( - SkillService, - Effect.gen(function* () { - const instance = yield* InstanceContext - const discovery = yield* DiscoveryService - - const skills: Record<string, Skill.Info> = {} - const skillDirs = new Set<string>() - let task: Promise<void> | undefined - - const addSkill = async (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 = Skill.Info.pick({ name: true, description: true }).safeParse(md.data) - if (!parsed.success) return - - // Warn on duplicate skill names - if (skills[parsed.data.name]) { - log.warn("duplicate skill name", { - name: parsed.data.name, - existing: skills[parsed.data.name].location, - duplicate: match, - }) - } - - skillDirs.add(path.dirname(match)) - - skills[parsed.data.name] = { - name: parsed.data.name, - description: parsed.data.description, - location: match, - content: md.content, - } - } - - const scanExternal = async (root: string, scope: "global" | "project") => { - return Glob.scan(EXTERNAL_SKILL_PATTERN, { - cwd: root, - absolute: true, - include: "file", - dot: true, - symlink: true, - }) - .then((matches) => Promise.all(matches.map(addSkill))) - .catch((error) => { - log.error(`failed to scan ${scope} skills`, { dir: root, error }) - }) - } - - function ensureScanned() { - if (task) return task - task = (async () => { - // Scan external skill directories (.claude/skills/, .agents/skills/, etc.) - // Load global (home) first, then project-level (so project-level overwrites) - 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 scanExternal(root, "global") - } - - for await (const root of Filesystem.up({ - targets: EXTERNAL_DIRS, - start: instance.directory, - stop: instance.project.worktree, - })) { - await scanExternal(root, "project") - } - } - - // Scan .opencode/skill/ directories - for (const dir of await Config.directories()) { - const matches = await Glob.scan(OPENCODE_SKILL_PATTERN, { - cwd: dir, - absolute: true, - include: "file", - symlink: true, - }) - for (const match of matches) { - await addSkill(match) - } - } - - // Scan additional skill paths from config - const config = await Config.get() - for (const skillPath of config.skills?.paths ?? []) { - const expanded = skillPath.startsWith("~/") ? path.join(os.homedir(), skillPath.slice(2)) : skillPath - const resolved = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded) - if (!(await Filesystem.isDir(resolved))) { - log.warn("skill path not found", { path: resolved }) - continue - } - const matches = await Glob.scan(SKILL_PATTERN, { - cwd: resolved, - absolute: true, - include: "file", - symlink: true, - }) - for (const match of matches) { - await addSkill(match) - } - } - - // Download and load skills from URLs - for (const url of config.skills?.urls ?? []) { - const list = await Effect.runPromise(discovery.pull(url)) - for (const dir of list) { - skillDirs.add(dir) - const matches = await Glob.scan(SKILL_PATTERN, { - cwd: dir, - absolute: true, - include: "file", - symlink: true, - }) - for (const match of matches) { - await addSkill(match) - } - } - } - - log.info("init", { count: Object.keys(skills).length }) - })().catch((err) => { - task = undefined - throw err - }) - return task - } - - return SkillService.of({ - get: Effect.fn("SkillService.get")(function* (name: string) { - yield* Effect.promise(() => ensureScanned()) - return skills[name] - }), - all: Effect.fn("SkillService.all")(function* () { - yield* Effect.promise(() => ensureScanned()) - return Object.values(skills) - }), - dirs: Effect.fn("SkillService.dirs")(function* () { - yield* Effect.promise(() => ensureScanned()) - return Array.from(skillDirs) - }), - available: Effect.fn("SkillService.available")(function* (agent?: Agent.Info) { - yield* Effect.promise(() => ensureScanned()) - const list = Object.values(skills) - if (!agent) return list - return list.filter( - (skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny", - ) - }), - }) - }), - ).pipe(Layer.provide(DiscoveryService.defaultLayer)) -} diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index a9489451c..9f0eef56b 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -9,20 +9,6 @@ import { Config } from "../config/config" import { Global } from "../global" import { Log } from "../util/log" -const log = Log.create({ service: "snapshot" }) -const PRUNE = "7.days" - -// Common git config flags shared across snapshot operations -const GIT_CORE = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"] -const GIT_CFG = ["-c", "core.autocrlf=false", ...GIT_CORE] -const GIT_CFG_QUOTE = [...GIT_CFG, "-c", "core.quotepath=false"] - -interface GitResult { - readonly code: ChildProcessSpawner.ExitCode - readonly text: string - readonly stderr: string -} - export namespace Snapshot { export const Patch = z.object({ hash: z.string(), @@ -44,43 +30,47 @@ export namespace Snapshot { }) export type FileDiff = z.infer<typeof FileDiff> - // Promise facade — existing callers use these - export function init() { - void runPromiseInstance(SnapshotService.use((s) => s.init())) - } - export async function cleanup() { - return runPromiseInstance(SnapshotService.use((s) => s.cleanup())) + return runPromiseInstance(Service.use((svc) => svc.cleanup())) } export async function track() { - return runPromiseInstance(SnapshotService.use((s) => s.track())) + return runPromiseInstance(Service.use((svc) => svc.track())) } export async function patch(hash: string) { - return runPromiseInstance(SnapshotService.use((s) => s.patch(hash))) + return runPromiseInstance(Service.use((svc) => svc.patch(hash))) } export async function restore(snapshot: string) { - return runPromiseInstance(SnapshotService.use((s) => s.restore(snapshot))) + return runPromiseInstance(Service.use((svc) => svc.restore(snapshot))) } export async function revert(patches: Patch[]) { - return runPromiseInstance(SnapshotService.use((s) => s.revert(patches))) + return runPromiseInstance(Service.use((svc) => svc.revert(patches))) } export async function diff(hash: string) { - return runPromiseInstance(SnapshotService.use((s) => s.diff(hash))) + return runPromiseInstance(Service.use((svc) => svc.diff(hash))) } export async function diffFull(from: string, to: string) { - return runPromiseInstance(SnapshotService.use((s) => s.diffFull(from, to))) + return runPromiseInstance(Service.use((svc) => svc.diffFull(from, to))) + } + + 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 namespace SnapshotService { - export interface Service { - readonly init: () => Effect.Effect<void> + export interface Interface { readonly cleanup: () => Effect.Effect<void> readonly track: () => Effect.Effect<string | undefined> readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch> @@ -89,99 +79,92 @@ export namespace SnapshotService { readonly diff: (hash: string) => Effect.Effect<string> readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]> } -} -export class SnapshotService extends ServiceMap.Service<SnapshotService, SnapshotService.Service>()( - "@opencode/Snapshot", -) { - static readonly layer = Layer.effect( - SnapshotService, + export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {} + + export const layer: Layer.Layer< + Service, + never, + InstanceContext | FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner + > = Layer.effect( + Service, Effect.gen(function* () { const ctx = yield* InstanceContext - const fileSystem = yield* FileSystem.FileSystem + const fs = yield* FileSystem.FileSystem const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - const { directory, worktree, project } = ctx - const isGit = project.vcs === "git" - const snapshotGit = path.join(Global.Path.data, "snapshot", project.id) + const directory = ctx.directory + const worktree = ctx.worktree + const project = ctx.project + const gitdir = path.join(Global.Path.data, "snapshot", project.id) - const gitArgs = (cmd: string[]) => ["--git-dir", snapshotGit, "--work-tree", worktree, ...cmd] + const args = (cmd: string[]) => ["--git-dir", gitdir, "--work-tree", worktree, ...cmd] - // Run git with nothrow semantics — always returns a result, never fails - const git = (args: string[], opts?: { cwd?: string; env?: Record<string, string> }): Effect.Effect<GitResult> => - Effect.gen(function* () { - const command = ChildProcess.make("git", args, { + const git = Effect.fnUntraced( + function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) { + const proc = ChildProcess.make("git", cmd, { cwd: opts?.cwd, env: opts?.env, extendEnv: true, }) - const handle = yield* spawner.spawn(command) + 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 } - }).pipe( - Effect.scoped, - Effect.catch((err) => - Effect.succeed({ - code: ChildProcessSpawner.ExitCode(1), - text: "", - stderr: String(err), - }), - ), - ) - - // FileSystem helpers — orDie converts PlatformError to defects - const exists = (p: string) => fileSystem.exists(p).pipe(Effect.orDie) - const mkdir = (p: string) => fileSystem.makeDirectory(p, { recursive: true }).pipe(Effect.orDie) - const writeFile = (p: string, content: string) => fileSystem.writeFileString(p, content).pipe(Effect.orDie) - const readFile = (p: string) => fileSystem.readFileString(p).pipe(Effect.catch(() => Effect.succeed(""))) - const removeFile = (p: string) => fileSystem.remove(p).pipe(Effect.catch(() => Effect.void)) + return { code, text, stderr } satisfies GitResult + }, + Effect.scoped, + Effect.catch((err) => + Effect.succeed({ + code: ChildProcessSpawner.ExitCode(1), + text: "", + stderr: String(err), + }), + ), + ) - // --- internal Effect helpers --- + const exists = (file: string) => fs.exists(file).pipe(Effect.orDie) + const mkdir = (dir: string) => fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie) + const write = (file: string, text: string) => fs.writeFileString(file, text).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 isEnabled = Effect.gen(function* () { - if (!isGit) return false - const cfg = yield* Effect.promise(() => Config.get()) - return cfg.snapshot !== false + const enabled = Effect.fnUntraced(function* () { + if (project.vcs !== "git") return false + return (yield* Effect.promise(() => Config.get())).snapshot !== false }) - const excludesPath = Effect.gen(function* () { + 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 undefined - if (!(yield* exists(file))) return undefined + if (!file) return + if (!(yield* exists(file))) return return file }) - const syncExclude = Effect.gen(function* () { - const file = yield* excludesPath - const target = path.join(snapshotGit, "info", "exclude") - yield* mkdir(path.join(snapshotGit, "info")) + const sync = Effect.fnUntraced(function* () { + const file = yield* excludes() + const target = path.join(gitdir, "info", "exclude") + yield* mkdir(path.join(gitdir, "info")) if (!file) { - yield* writeFile(target, "") + yield* write(target, "") return } - const text = yield* readFile(file) - yield* writeFile(target, text) + yield* write(target, yield* read(file)) }) - const add = Effect.gen(function* () { - yield* syncExclude - yield* git([...GIT_CFG, ...gitArgs(["add", "."])], { cwd: directory }) + const add = Effect.fnUntraced(function* () { + yield* sync() + yield* git([...cfg, ...args(["add", "."])], { cwd: directory }) }) - // --- service methods --- - - const cleanup = Effect.fn("SnapshotService.cleanup")(function* () { - if (!(yield* isEnabled)) return - if (!(yield* exists(snapshotGit))) return - const result = yield* git(gitArgs(["gc", `--prune=${PRUNE}`]), { - 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, @@ -189,58 +172,55 @@ export class SnapshotService extends ServiceMap.Service<SnapshotService, Snapsho }) return } - log.info("cleanup", { prune: PRUNE }) + log.info("cleanup", { prune }) }) - const track = Effect.fn("SnapshotService.track")(function* () { - if (!(yield* isEnabled)) return undefined - const existed = yield* exists(snapshotGit) - yield* mkdir(snapshotGit) + const track = Effect.fn("Snapshot.track")(function* () { + if (!(yield* enabled())) return + const existed = yield* exists(gitdir) + yield* mkdir(gitdir) if (!existed) { yield* git(["init"], { - env: { GIT_DIR: snapshotGit, GIT_WORK_TREE: worktree }, + env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree }, }) - yield* git(["--git-dir", snapshotGit, "config", "core.autocrlf", "false"]) - yield* git(["--git-dir", snapshotGit, "config", "core.longpaths", "true"]) - yield* git(["--git-dir", snapshotGit, "config", "core.symlinks", "true"]) - yield* git(["--git-dir", snapshotGit, "config", "core.fsmonitor", "false"]) + 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(gitArgs(["write-tree"]), { cwd: directory }) + yield* add() + const result = yield* git(args(["write-tree"]), { cwd: directory }) const hash = result.text.trim() - log.info("tracking", { hash, cwd: directory, git: snapshotGit }) + log.info("tracking", { hash, cwd: directory, git: gitdir }) return hash }) - const patch = Effect.fn("SnapshotService.patch")(function* (hash: string) { - yield* add - const result = yield* git( - [...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])], - { cwd: directory }, - ) - + 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: [] } as Snapshot.Patch + return { hash, files: [] } } - return { hash, files: result.text .trim() .split("\n") - .map((x: string) => x.trim()) + .map((x) => x.trim()) .filter(Boolean) - .map((x: string) => path.join(worktree, x).replaceAll("\\", "/")), - } as Snapshot.Patch + .map((x) => path.join(worktree, x).replaceAll("\\", "/")), + } }) - const restore = Effect.fn("SnapshotService.restore")(function* (snapshot: string) { + const restore = Effect.fn("Snapshot.restore")(function* (snapshot: string) { log.info("restore", { commit: snapshot }) - const result = yield* git([...GIT_CORE, ...gitArgs(["read-tree", snapshot])], { cwd: worktree }) + const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: worktree }) if (result.code === 0) { - const checkout = yield* git([...GIT_CORE, ...gitArgs(["checkout-index", "-a", "-f"])], { cwd: worktree }) + const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: worktree }) if (checkout.code === 0) return log.error("failed to restore snapshot", { snapshot, @@ -256,38 +236,33 @@ export class SnapshotService extends ServiceMap.Service<SnapshotService, Snapsho }) }) - const revert = Effect.fn("SnapshotService.revert")(function* (patches: Snapshot.Patch[]) { + const revert = Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) { const seen = new Set<string>() 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([...GIT_CORE, ...gitArgs(["checkout", item.hash, "--", file])], { - cwd: worktree, - }) + const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], { cwd: worktree }) if (result.code !== 0) { - const relativePath = path.relative(worktree, file) - const checkTree = yield* git([...GIT_CORE, ...gitArgs(["ls-tree", item.hash, "--", relativePath])], { - cwd: worktree, - }) - if (checkTree.code === 0 && checkTree.text.trim()) { + 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* removeFile(file) + yield* remove(file) } } - seen.add(file) } } }) - const diff = Effect.fn("SnapshotService.diff")(function* (hash: string) { - yield* add - const result = yield* git([...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", hash, "--", "."])], { + 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, @@ -296,19 +271,15 @@ export class SnapshotService extends ServiceMap.Service<SnapshotService, Snapsho }) return "" } - return result.text.trim() }) - const diffFull = Effect.fn("SnapshotService.diffFull")(function* (from: string, to: string) { + const diffFull = Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) { const result: Snapshot.FileDiff[] = [] const status = new Map<string, "added" | "deleted" | "modified">() const statuses = yield* git( - [ - ...GIT_CFG_QUOTE, - ...gitArgs(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]), - ], + [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])], { cwd: directory }, ) @@ -316,64 +287,60 @@ export class SnapshotService extends ServiceMap.Service<SnapshotService, Snapsho if (!line) continue const [code, file] = line.split("\t") if (!code || !file) continue - const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified" - status.set(file, kind) + status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified") } const numstat = yield* git( - [...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])], - { cwd: directory }, + [...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 [additions, deletions, file] = line.split("\t") - const isBinaryFile = additions === "-" && deletions === "-" - const [before, after] = isBinaryFile + const [adds, dels, file] = line.split("\t") + if (!file) continue + const binary = adds === "-" && dels === "-" + const [before, after] = binary ? ["", ""] : yield* Effect.all( [ - git([...GIT_CFG, ...gitArgs(["show", `${from}:${file}`])]).pipe(Effect.map((r) => r.text)), - git([...GIT_CFG, ...gitArgs(["show", `${to}:${file}`])]).pipe(Effect.map((r) => r.text)), + 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 added = isBinaryFile ? 0 : parseInt(additions!) - const deleted = isBinaryFile ? 0 : parseInt(deletions!) + const additions = binary ? 0 : parseInt(adds) + const deletions = binary ? 0 : parseInt(dels) result.push({ - file: file!, + file, before, after, - additions: Number.isFinite(added) ? added : 0, - deletions: Number.isFinite(deleted) ? deleted : 0, - status: status.get(file!) ?? "modified", + additions: Number.isFinite(additions) ? additions : 0, + deletions: Number.isFinite(deletions) ? deletions : 0, + status: status.get(file) ?? "modified", }) } + return result }) - // Start hourly cleanup fiber — scoped to instance lifetime 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 SnapshotService.of({ - init: Effect.fn("SnapshotService.init")(function* () {}), - cleanup, - track, - patch, - restore, - revert, - diff, - diffFull, - }) + return Service.of({ cleanup, track, patch, restore, revert, diff, diffFull }) }), - ).pipe( + ) + + export const defaultLayer = layer.pipe( Layer.provide(NodeChildProcessSpawner.layer), Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer), diff --git a/packages/opencode/src/tool/truncate-effect.ts b/packages/opencode/src/tool/truncate-effect.ts index 4d0ed8168..aa6c999fc 100644 --- a/packages/opencode/src/tool/truncate-effect.ts +++ b/packages/opencode/src/tool/truncate-effect.ts @@ -2,7 +2,7 @@ import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap } from "effect" import path from "path" import type { Agent } from "../agent/agent" -import { PermissionEffect } from "../permission/service" +import { PermissionNext } from "../permission" import { Identifier } from "../id/id" import { Log } from "../util/log" import { ToolID } from "./schema" @@ -27,10 +27,10 @@ export namespace TruncateEffect { function hasTaskTool(agent?: Agent.Info) { if (!agent?.permission) return false - return PermissionEffect.evaluate("task", "*", agent.permission).action !== "deny" + return PermissionNext.evaluate("task", "*", agent.permission).action !== "deny" } - export interface Api { + export interface Interface { readonly cleanup: () => Effect.Effect<void> /** * Returns output unchanged when it fits within the limits, otherwise writes the full text @@ -39,14 +39,14 @@ export namespace TruncateEffect { readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result> } - export class Service extends ServiceMap.Service<Service, Api>()("@opencode/Truncate") {} + export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Truncate") {} export const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FileSystem.FileSystem - const cleanup = Effect.fn("TruncateEffect.cleanup")(function* () { + 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_"))), @@ -58,7 +58,7 @@ export namespace TruncateEffect { } }) - const output = Effect.fn("TruncateEffect.output")(function* ( + const output = Effect.fn("Truncate.output")(function* ( text: string, options: Options = {}, agent?: Agent.Info, diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts index d7239bfbf..94cd9eb94 100644 --- a/packages/opencode/test/account/service.test.ts +++ b/packages/opencode/test/account/service.test.ts @@ -3,7 +3,7 @@ import { Duration, Effect, Layer, Option, Schema } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { AccountRepo } from "../../src/account/repo" -import { AccountService } from "../../src/account/service" +import { AccountEffect } from "../../src/account/effect" import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema" import { Database } from "../../src/storage/db" import { testEffect } from "../lib/effect" @@ -19,7 +19,7 @@ const truncate = Layer.effectDiscard( const it = testEffect(Layer.merge(AccountRepo.layer, truncate)) const live = (client: HttpClient.HttpClient) => - AccountService.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client))) + AccountEffect.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client))) const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) => HttpClientResponse.fromWeb( @@ -77,7 +77,7 @@ it.effect("orgsByAccount groups orgs per account", () => }), ) - const rows = yield* AccountService.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client))) + const rows = yield* AccountEffect.Service.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client))) expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([ [AccountID.make("user-1"), [OrgID.make("org-1")]], @@ -115,7 +115,7 @@ it.effect("token refresh persists the new token", () => ), ) - const token = yield* AccountService.use((s) => s.token(id)).pipe(Effect.provide(live(client))) + const token = yield* AccountEffect.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client))) expect(Option.getOrThrow(token)).toBeDefined() expect(String(Option.getOrThrow(token))).toBe("at_new") @@ -158,7 +158,9 @@ it.effect("config sends the selected org header", () => }), ) - const cfg = yield* AccountService.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client))) + const cfg = yield* AccountEffect.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe( + Effect.provide(live(client)), + ) expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 }) expect(seen).toEqual({ @@ -196,7 +198,7 @@ it.effect("poll stores the account and first org on success", () => ), ) - const res = yield* AccountService.use((s) => s.poll(login)).pipe(Effect.provide(live(client))) + const res = yield* AccountEffect.Service.use((s) => s.poll(login)).pipe(Effect.provide(live(client))) expect(res._tag).toBe("PollSuccess") if (res._tag === "PollSuccess") { diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index a2de61733..8a3d30d31 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -5,7 +5,7 @@ import path from "path" import { Deferred, Effect, Fiber, Option } from "effect" import { tmpdir } from "../fixture/fixture" import { watcherConfigLayer, withServices } from "../fixture/instance" -import { FileWatcher, FileWatcherService } from "../../src/file/watcher" +import { FileWatcher } from "../../src/file/watcher" import { Instance } from "../../src/project/instance" import { GlobalBus } from "../../src/bus/global" @@ -19,13 +19,12 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } } type WatcherEvent = { file: string; event: "add" | "change" | "unlink" } -/** Run `body` with a live FileWatcherService. */ +/** Run `body` with a live FileWatcher service. */ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) { return withServices( directory, - FileWatcherService.layer, + FileWatcher.layer, async (rt) => { - await rt.runPromise(FileWatcherService.use((s) => s.init())) await Effect.runPromise(ready(directory)) await Effect.runPromise(body) }, @@ -138,7 +137,7 @@ function ready(directory: string) { // Tests // --------------------------------------------------------------------------- -describeWatcher("FileWatcherService", () => { +describeWatcher("FileWatcher", () => { afterEach(() => Instance.disposeAll()) test("publishes root create, update, and delete events", async () => { diff --git a/packages/opencode/test/format/format.test.ts b/packages/opencode/test/format/format.test.ts index 610850d47..2718e125d 100644 --- a/packages/opencode/test/format/format.test.ts +++ b/packages/opencode/test/format/format.test.ts @@ -1,17 +1,18 @@ +import { Effect } from "effect" import { afterEach, describe, expect, test } from "bun:test" import { tmpdir } from "../fixture/fixture" import { withServices } from "../fixture/instance" -import { FormatService } from "../../src/format" +import { Format } from "../../src/format" import { Instance } from "../../src/project/instance" -describe("FormatService", () => { +describe("Format", () => { afterEach(() => Instance.disposeAll()) test("status() returns built-in formatters when no config overrides", async () => { await using tmp = await tmpdir() - await withServices(tmp.path, FormatService.layer, async (rt) => { - const statuses = await rt.runPromise(FormatService.use((s) => s.status())) + await withServices(tmp.path, Format.layer, async (rt) => { + const statuses = await rt.runPromise(Format.Service.use((s) => s.status())) expect(Array.isArray(statuses)).toBe(true) expect(statuses.length).toBeGreaterThan(0) @@ -32,8 +33,8 @@ describe("FormatService", () => { config: { formatter: false }, }) - await withServices(tmp.path, FormatService.layer, async (rt) => { - const statuses = await rt.runPromise(FormatService.use((s) => s.status())) + await withServices(tmp.path, Format.layer, async (rt) => { + const statuses = await rt.runPromise(Format.Service.use((s) => s.status())) expect(statuses).toEqual([]) }) }) @@ -47,18 +48,18 @@ describe("FormatService", () => { }, }) - await withServices(tmp.path, FormatService.layer, async (rt) => { - const statuses = await rt.runPromise(FormatService.use((s) => s.status())) + await withServices(tmp.path, Format.layer, async (rt) => { + const statuses = await rt.runPromise(Format.Service.use((s) => s.status())) const gofmt = statuses.find((s) => s.name === "gofmt") expect(gofmt).toBeUndefined() }) }) - test("init() completes without error", async () => { + test("service initializes without error", async () => { await using tmp = await tmpdir() - await withServices(tmp.path, FormatService.layer, async (rt) => { - await rt.runPromise(FormatService.use((s) => s.init())) + await withServices(tmp.path, Format.layer, async (rt) => { + await rt.runPromise(Format.Service.use(() => Effect.void)) }) }) }) diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 6fa782b05..2a6b6e0ba 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -5,7 +5,7 @@ import { Bus } from "../../src/bus" import { runtime } from "../../src/effect/runtime" import { Instances } from "../../src/effect/instances" import { PermissionNext } from "../../src/permission" -import * as S from "../../src/permission/service" +import { PermissionNext as S } from "../../src/permission" import { PermissionID } from "../../src/permission/schema" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" @@ -1005,7 +1005,7 @@ test("ask - abort should clear pending request", async () => { fn: async () => { const ctl = new AbortController() const ask = runtime.runPromise( - S.PermissionEffect.Service.use((svc) => + S.Service.use((svc) => svc.ask({ sessionID: SessionID.make("session_test"), permission: "bash", diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index d8f8ea455..0095ff387 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -4,6 +4,7 @@ import fs from "fs/promises" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { ProviderAuth } from "../../src/provider/auth" +import { ProviderID } from "../../src/provider/schema" describe("plugin.auth-override", () => { test("user plugin overrides built-in github-copilot auth", async () => { @@ -34,7 +35,7 @@ describe("plugin.auth-override", () => { directory: tmp.path, fn: async () => { const methods = await ProviderAuth.methods() - const copilot = methods["github-copilot"] + const copilot = methods[ProviderID.make("github-copilot")] expect(copilot).toBeDefined() expect(copilot.length).toBe(1) expect(copilot[0].label).toBe("Test Override Auth") diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index b5100585f..90f445ed7 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -2,13 +2,13 @@ import { $ } from "bun" import { afterEach, describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" -import { Layer, ManagedRuntime } from "effect" +import { Effect, Layer, ManagedRuntime } from "effect" import { tmpdir } from "../fixture/fixture" import { watcherConfigLayer, withServices } from "../fixture/instance" -import { FileWatcher, FileWatcherService } from "../../src/file/watcher" +import { FileWatcher } from "../../src/file/watcher" import { Instance } from "../../src/project/instance" import { GlobalBus } from "../../src/bus/global" -import { Vcs, VcsService } from "../../src/project/vcs" +import { Vcs } from "../../src/project/vcs" // Skip in CI — native @parcel/watcher binding needed const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip @@ -19,15 +19,15 @@ const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe function withVcs( directory: string, - body: (rt: ManagedRuntime.ManagedRuntime<FileWatcherService | VcsService, never>) => Promise<void>, + body: (rt: ManagedRuntime.ManagedRuntime<FileWatcher.Service | Vcs.Service, never>) => Promise<void>, ) { return withServices( directory, - Layer.merge(FileWatcherService.layer, VcsService.layer), + Layer.merge(FileWatcher.layer, Vcs.layer), async (rt) => { - await rt.runPromise(FileWatcherService.use((s) => s.init())) - await rt.runPromise(VcsService.use((s) => s.init())) - await Bun.sleep(200) + await rt.runPromise(FileWatcher.Service.use(() => Effect.void)) + await rt.runPromise(Vcs.Service.use(() => Effect.void)) + await Bun.sleep(500) await body(rt) }, { provide: [watcherConfigLayer] }, @@ -36,10 +36,14 @@ function withVcs( type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } } -/** Wait for a Vcs.Event.BranchUpdated event on GlobalBus */ -function nextBranchUpdate(directory: string, timeout = 5000) { +/** Wait for a Vcs.Event.BranchUpdated event on GlobalBus, with retry polling as fallback */ +function nextBranchUpdate(directory: string, timeout = 10_000) { return new Promise<string | undefined>((resolve, reject) => { + let settled = false + const timer = setTimeout(() => { + if (settled) return + settled = true GlobalBus.off("event", on) reject(new Error("timed out waiting for BranchUpdated event")) }, timeout) @@ -47,6 +51,8 @@ function nextBranchUpdate(directory: string, timeout = 5000) { function on(evt: BranchEvent) { if (evt.directory !== directory) return if (evt.payload.type !== Vcs.Event.BranchUpdated.type) return + if (settled) return + settled = true clearTimeout(timer) GlobalBus.off("event", on) resolve(evt.payload.properties.branch) @@ -67,7 +73,7 @@ describeVcs("Vcs", () => { await using tmp = await tmpdir({ git: true }) await withVcs(tmp.path, async (rt) => { - const branch = await rt.runPromise(VcsService.use((s) => s.branch())) + const branch = await rt.runPromise(Vcs.Service.use((s) => s.branch())) expect(branch).toBeDefined() expect(typeof branch).toBe("string") }) @@ -77,7 +83,7 @@ describeVcs("Vcs", () => { await using tmp = await tmpdir() await withVcs(tmp.path, async (rt) => { - const branch = await rt.runPromise(VcsService.use((s) => s.branch())) + const branch = await rt.runPromise(Vcs.Service.use((s) => s.branch())) expect(branch).toBeUndefined() }) }) @@ -110,7 +116,7 @@ describeVcs("Vcs", () => { await fs.writeFile(head, `ref: refs/heads/${branch}\n`) await pending - const current = await rt.runPromise(VcsService.use((s) => s.branch())) + const current = await rt.runPromise(Vcs.Service.use((s) => s.branch())) expect(current).toBe(branch) }) }) diff --git a/packages/opencode/test/server/project-init-git.test.ts b/packages/opencode/test/server/project-init-git.test.ts index cc1ac0cbc..839ba4607 100644 --- a/packages/opencode/test/server/project-init-git.test.ts +++ b/packages/opencode/test/server/project-init-git.test.ts @@ -68,6 +68,7 @@ describe("project.initGit endpoint", () => { }, }) } finally { + await Instance.disposeAll() reloadSpy.mockRestore() GlobalBus.off("event", fn) } @@ -111,7 +112,9 @@ describe("project.initGit endpoint", () => { vcs: "git", worktree: tmp.path, }) + } finally { + await Instance.disposeAll() reloadSpy.mockRestore() GlobalBus.off("event", fn) } diff --git a/packages/opencode/test/skill/discovery.test.ts b/packages/opencode/test/skill/discovery.test.ts index 5cbb3ada0..de356ef15 100644 --- a/packages/opencode/test/skill/discovery.test.ts +++ b/packages/opencode/test/skill/discovery.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, beforeAll, afterAll } from "bun:test" import { Effect } from "effect" -import { DiscoveryService } from "../../src/skill/discovery" +import { Discovery } from "../../src/skill/discovery" import { Global } from "../../src/global" import { Filesystem } from "../../src/util/filesystem" import { rm } from "fs/promises" @@ -48,7 +48,7 @@ afterAll(async () => { describe("Discovery.pull", () => { const pull = (url: string) => - Effect.runPromise(DiscoveryService.use((s) => s.pull(url)).pipe(Effect.provide(DiscoveryService.defaultLayer))) + Effect.runPromise(Discovery.Service.use((s) => s.pull(url)).pipe(Effect.provide(Discovery.defaultLayer))) test("downloads skills from cloudflare url", async () => { const dirs = await pull(CLOUDFLARE_SKILLS_URL) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 1804ab5c2..203050287 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -1178,3 +1178,37 @@ test("diffFull with whitespace changes", async () => { }, }) }) + +test("revert with overlapping files across patches uses first patch hash", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Write initial content and snapshot + await Filesystem.write(`${tmp.path}/shared.txt`, "v1") + const snap1 = await Snapshot.track() + expect(snap1).toBeTruthy() + + // Modify and snapshot again + await Filesystem.write(`${tmp.path}/shared.txt`, "v2") + const snap2 = await Snapshot.track() + expect(snap2).toBeTruthy() + + // Modify once more so both patches include shared.txt + await Filesystem.write(`${tmp.path}/shared.txt`, "v3") + + const patch1 = await Snapshot.patch(snap1!) + const patch2 = await Snapshot.patch(snap2!) + + // Both patches should include shared.txt + expect(patch1.files).toContain(fwd(tmp.path, "shared.txt")) + expect(patch2.files).toContain(fwd(tmp.path, "shared.txt")) + + // Revert with patch1 first — should use snap1's hash (restoring "v1") + await Snapshot.revert([patch1, patch2]) + + const content = await fs.readFile(`${tmp.path}/shared.txt`, "utf-8") + expect(content).toBe("v1") + }, + }) +}) |
