Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 30 additions & 46 deletions packages/opencode/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
144 changes: 144 additions & 0 deletions packages/opencode/specs/effect-migration.md
Original file line number Diff line number Diff line change
@@ -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`
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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 []

Expand All @@ -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()

Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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))
}
14 changes: 7 additions & 7 deletions packages/opencode/src/account/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading