mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-13 15:44:56 +00:00
docs(effect): add cleanup roadmap (#27228)
This commit is contained in:
@@ -1,41 +1,22 @@
|
||||
# Typed error migration
|
||||
# Typed Error Migration
|
||||
|
||||
Plan for moving `packages/opencode` from temporary defect/`NamedError`
|
||||
compatibility toward typed Effect service errors and explicit HTTP error
|
||||
contracts.
|
||||
This note expands the `ERR`, `RENDER`, and `HTTP` tracks from
|
||||
[`todo.md`](./todo.md). It is the current reference for expected failures,
|
||||
typed service errors, and HTTP error boundaries.
|
||||
|
||||
## Goal
|
||||
|
||||
- Expected service failures live on the Effect error channel.
|
||||
- Service interfaces expose those failures in their return types.
|
||||
- Domain errors are authored with Effect Schema so they are reusable by services,
|
||||
tests, HTTP routes, tools, and OpenAPI generation.
|
||||
- HTTP status codes and wire compatibility are handled at the HTTP boundary, not
|
||||
inside service modules.
|
||||
- `Effect.die`, `throw`, `catchDefect`, and global cause inspection are reserved
|
||||
for defects, compatibility bridges, or final fallback behavior.
|
||||
- Domain errors are authored with `Schema.TaggedErrorClass`.
|
||||
- `Effect.die(...)` is reserved for defects: bugs, impossible states,
|
||||
violated invariants, and final unknown-boundary fallbacks.
|
||||
- HTTP status codes and public wire bodies are handled at HTTP route
|
||||
boundaries, not inside service modules.
|
||||
- User-facing boundaries render useful structured error details instead of
|
||||
opaque `Error: SomeName` strings.
|
||||
|
||||
## Current State
|
||||
|
||||
- Many migrated services use Effect internally, but expected failures are still a
|
||||
mix of `NamedError.create(...)`, `namedSchemaError(...)`, `class extends Error`,
|
||||
`throw`, and `Effect.die(...)`.
|
||||
- Some services already use `Schema.TaggedErrorClass`, for example `Account`,
|
||||
`Auth`, `Permission`, `Question`, `Installation`, and parts of
|
||||
`Workspace`.
|
||||
- The temporary HttpApi compatibility middleware recognizes `NamedError`,
|
||||
`Session.BusyError`, and a few name-based cases, then emits the legacy
|
||||
`{ name, data }` JSON body.
|
||||
- Effect `HttpApi` only knows how to encode errors that are declared on the
|
||||
endpoint, group, or middleware. Undeclared expected errors become defects and
|
||||
eventually fall through to generic HTTP handling.
|
||||
- The temporary HttpApi error middleware catches defect-wrapped legacy errors to
|
||||
preserve runtime behavior, but it is intentionally a bridge rather than the
|
||||
final model.
|
||||
|
||||
## End State
|
||||
|
||||
Service modules own domain failures.
|
||||
## Service Error Shape
|
||||
|
||||
```ts
|
||||
export class SessionBusyError extends Schema.TaggedErrorClass<SessionBusyError>()("SessionBusyError", {
|
||||
@@ -50,281 +31,90 @@ export interface Interface {
|
||||
}
|
||||
```
|
||||
|
||||
HTTP modules own transport mapping.
|
||||
Rules:
|
||||
|
||||
- Use `Schema.TaggedErrorClass` for expected domain failures.
|
||||
- Export a domain-level `Error` union from each service module.
|
||||
- Put expected errors in service method signatures.
|
||||
- Use `yield* new DomainError(...)` for direct early failures in
|
||||
`Effect.gen` / `Effect.fn`.
|
||||
- Use `Schema.Defect` for unknown cause fields when preserving the cause is
|
||||
useful for logs or callers.
|
||||
- Use `Effect.try(...)`, `Effect.tryPromise(...)`, `Effect.mapError`,
|
||||
`Effect.catchTag`, and `Effect.catchTags` to translate external
|
||||
failures into domain errors.
|
||||
- Do not use `throw`, `Effect.die(...)`, or `catchDefect` for expected
|
||||
user, IO, validation, missing-resource, auth, provider, worktree, or
|
||||
busy-state failures.
|
||||
|
||||
## HTTP Boundary Shape
|
||||
|
||||
Service modules stay transport-agnostic. They should not import HTTP
|
||||
status codes, `HttpApiError`, `HttpServerResponse`, or route-specific
|
||||
error schemas.
|
||||
|
||||
HTTP handlers translate service errors into public endpoint errors:
|
||||
|
||||
```ts
|
||||
const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) {
|
||||
return yield* session
|
||||
.get(ctx.params.sessionID)
|
||||
.pipe(
|
||||
Effect.catchTag("StorageNotFoundError", () => new SessionNotFoundHttpError({ sessionID: ctx.params.sessionID })),
|
||||
)
|
||||
.pipe(Effect.catchTag("StorageNotFoundError", () => notFound("Session not found")))
|
||||
})
|
||||
```
|
||||
|
||||
HTTP-visible error schemas carry their own response status through Effect
|
||||
HttpApi's `httpApiStatus` annotation. Prefer `HttpApiSchema.status(...)`, or the
|
||||
equivalent declaration annotation, instead of maintaining a parallel status map.
|
||||
Endpoint definitions declare which public errors can be emitted. Public
|
||||
HTTP error schemas carry their response status with `httpApiStatus` or the
|
||||
equivalent HttpApi schema annotation.
|
||||
|
||||
```ts
|
||||
export class SessionNotFoundHttpError extends Schema.TaggedErrorClass<SessionNotFoundHttpError>()(
|
||||
"SessionNotFoundHttpError",
|
||||
{
|
||||
sessionID: SessionID,
|
||||
message: Schema.String,
|
||||
},
|
||||
{ httpApiStatus: 404 },
|
||||
) {}
|
||||
```
|
||||
The service error and HTTP error may be the same class only when the wire
|
||||
shape is intentionally public. Use separate HTTP error schemas when the
|
||||
service error contains internals, low-level causes, retry hints, or data
|
||||
that should not be exposed to API clients.
|
||||
|
||||
Endpoint definitions still declare which HTTP-visible error schemas can be
|
||||
emitted. The status annotation is only used if the error is part of the endpoint,
|
||||
group, or middleware error schema and the handler fails with that error on the
|
||||
typed error channel.
|
||||
## Mapping Guidance
|
||||
|
||||
```ts
|
||||
HttpApiEndpoint.get("get", SessionPaths.get, {
|
||||
success: Session.Info,
|
||||
error: [SessionNotFoundHttpError, SessionBusyHttpError],
|
||||
})
|
||||
```
|
||||
- Keep one-off translations inline in the handler.
|
||||
- Extract tiny shared helpers when the same translation repeats across a
|
||||
route group.
|
||||
- Do not create one giant `unknown -> status` mapper.
|
||||
- Do not grow generic HTTP middleware into a registry of domain errors.
|
||||
- Preserve existing public `{ name, data }` bodies until a deliberate
|
||||
breaking API change.
|
||||
- Use built-in `HttpApiError.*` only when its generated body and SDK
|
||||
surface are intentionally the public contract.
|
||||
|
||||
The service error and HTTP error may be the same class when the wire shape is a
|
||||
deliberate public contract. They should be different classes when the service
|
||||
error contains internals, low-level causes, retry hints, or anything that should
|
||||
not be exposed to API clients.
|
||||
## Middleware Guidance
|
||||
|
||||
## Rules
|
||||
HTTP middleware should be cross-cutting: auth, context, schema decode
|
||||
formatting, routing, and final unknown-defect fallback.
|
||||
|
||||
- Use `Schema.TaggedErrorClass` for new expected domain errors.
|
||||
- Include `cause: Schema.optional(Schema.Defect)` only when preserving an
|
||||
underlying unknown failure is useful for logs or callers.
|
||||
- Export a domain-level error union from each service module, for example
|
||||
`export type Error = NotFoundError | BusyError | Storage.Error`.
|
||||
- Put expected errors in service method signatures, for example
|
||||
`Effect.Effect<Result, Service.Error, R>`.
|
||||
- Use `yield* new DomainError(...)` for direct early failures inside
|
||||
`Effect.gen` / `Effect.fn`.
|
||||
- Use `Effect.try({ try, catch })`, `Effect.mapError`, or `Effect.catchTag` to
|
||||
convert external exceptions into domain errors.
|
||||
- Use `HttpApiSchema.status(...)` or `{ httpApiStatus: code }` on HTTP-visible
|
||||
error schemas so Effect `HttpApiBuilder` and OpenAPI generation get the status
|
||||
from the schema itself.
|
||||
- Do not use `Effect.die(...)` for user, IO, validation, missing-resource, auth,
|
||||
provider, worktree, or busy-state failures.
|
||||
- Do not use `catchDefect` to recover expected domain errors. If recovery is
|
||||
needed, the upstream effect should fail with a typed error instead.
|
||||
- Do not make service modules import `HttpApiError`, `HttpServerResponse`, HTTP
|
||||
status codes, or route-specific error schemas.
|
||||
- Keep raw `HttpRouter` routes free to use `HttpServerRespondable` when that is
|
||||
the right transport abstraction, but prefer declared `HttpApi` errors for
|
||||
normal JSON API endpoints.
|
||||
The current compatibility middleware still knows about some legacy domain
|
||||
errors. As route groups declare expected errors and handlers map them, that
|
||||
middleware should shrink. It should not gain new name checks.
|
||||
|
||||
## HTTP Boundary Shape
|
||||
Unknown `500` responses should log full details server-side with
|
||||
`Cause.pretty(cause)` and return a safe public body.
|
||||
|
||||
Create an HttpApi-local error module, likely
|
||||
`src/server/routes/instance/httpapi/errors.ts`.
|
||||
## Migration Order
|
||||
|
||||
That module should provide:
|
||||
Prefer small vertical slices:
|
||||
|
||||
- Legacy-compatible public schemas for `{ name, data }` error bodies that must
|
||||
remain SDK-compatible while route groups declare typed errors.
|
||||
- Small constructors or mapping helpers for common API errors such as not found,
|
||||
bad request, conflict, and unknown internal errors.
|
||||
- Route-group-specific adapters only when they encode domain-specific public
|
||||
data.
|
||||
- A single place to document which public error shape is legacy-compatible and
|
||||
which shape is new Effect-native API surface.
|
||||
1. Fix rendering at one user-visible boundary.
|
||||
2. Convert one service domain to `Schema.TaggedErrorClass` errors.
|
||||
3. Map those errors at the affected HTTP handlers.
|
||||
4. Remove the corresponding name-based middleware branch if possible.
|
||||
5. Add or update focused tests for both service error tags and HTTP wire
|
||||
bodies.
|
||||
|
||||
Avoid one giant `unknown -> status` mapper. Prefer small, explicit mappers close
|
||||
to the handler or route group.
|
||||
Good early domains are storage not-found, worktree errors, and provider
|
||||
auth validation errors because they currently drive HTTP behavior.
|
||||
|
||||
```ts
|
||||
const mapSessionError = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(
|
||||
Effect.catchTag("StorageNotFoundError", (error) => new SessionNotFoundHttpError({ message: error.message })),
|
||||
Effect.catchTag("SessionBusyError", (error) => new SessionBusyHttpError({ message: error.message })),
|
||||
)
|
||||
```
|
||||
## Checklist For A PR
|
||||
|
||||
Use built-in `HttpApiError.BadRequest`, `HttpApiError.NotFound`, and related
|
||||
types only when their generated response body and SDK surface are intentionally
|
||||
acceptable. Use a custom schema-backed error when clients need the legacy
|
||||
`{ name, data }` body or a domain-specific error payload.
|
||||
|
||||
## Migration Phases
|
||||
|
||||
### 1. Stabilize The Bridge
|
||||
|
||||
Keep the temporary HttpApi error middleware only as a compatibility bridge while
|
||||
typed errors are introduced.
|
||||
|
||||
- Add tests that prove the bridge catches legacy `NamedError` defects.
|
||||
- Add tests that prove declared HttpApi errors still use the declared endpoint
|
||||
contract.
|
||||
- Stop returning stack traces in unknown HTTP `500` responses; log the full
|
||||
`Cause.pretty(cause)` server-side instead.
|
||||
- Add a comment or TODO that names this plan and states the bridge must shrink
|
||||
as route groups migrate.
|
||||
|
||||
### 2. Define The Shared HTTP Error Helpers
|
||||
|
||||
Add the `httpapi/errors.ts` module before converting route groups.
|
||||
|
||||
- Define a legacy `{ name, data }` body helper for SDK-compatible errors.
|
||||
- Define `UnknownError` for generic internal failures with a safe public message.
|
||||
- Define `BadRequestError` and `NotFoundError` equivalents only if the actual
|
||||
wire body must match the existing SDK surface.
|
||||
- Put the HTTP status on the public schema with `HttpApiSchema.status(...)` or
|
||||
`{ httpApiStatus: code }`; do not keep a separate name-to-status table.
|
||||
- Keep conversion helpers pure and small. They should not inspect `Cause` or
|
||||
accept `unknown` unless they are final fallback helpers.
|
||||
|
||||
### 3. Convert One Vertical Slice
|
||||
|
||||
Start with session read routes because they already have local `mapNotFound`
|
||||
logic and are heavily covered by existing HttpApi tests.
|
||||
|
||||
- Convert `Session.BusyError` from a plain `Error` to a typed service error, or
|
||||
add a typed wrapper while preserving the old constructor until callers are
|
||||
migrated.
|
||||
- Replace `catchDefect` in `httpapi/handlers/session.ts` with typed error
|
||||
mapping.
|
||||
- Add endpoint error schemas for the affected session endpoints.
|
||||
- Prove behavior with focused tests in `test/server/httpapi-session.test.ts`.
|
||||
- Remove the migrated cases from the global compatibility middleware.
|
||||
|
||||
### 4. Convert Legacy NamedError Domains
|
||||
|
||||
Move legacy `NamedError.create(...)` services to Effect Schema-backed errors in
|
||||
small domain PRs.
|
||||
|
||||
Priority order:
|
||||
|
||||
1. `storage/storage.ts` and `storage/db.ts` not-found errors.
|
||||
2. `worktree/index.ts` `Worktree*` errors.
|
||||
3. `provider/auth.ts` validation failures and `provider/provider.ts` model-not-found errors.
|
||||
4. `mcp/index.ts`, `skill/index.ts`, `lsp/client.ts`, and `ide/index.ts` service errors.
|
||||
5. Config and CLI-only errors after HTTP-facing domains are stable.
|
||||
|
||||
For each domain:
|
||||
|
||||
- Replace `NamedError.create(...)` with `Schema.TaggedErrorClass` when the error
|
||||
is primarily a service error.
|
||||
- Keep or add a separate HTTP error schema when the legacy `{ name, data }` wire
|
||||
shape must remain stable.
|
||||
- Update service interface return types to include the new error union.
|
||||
- Replace `throw new X(...)` inside `Effect.fn` with `yield* new X(...)`.
|
||||
- Replace async exceptions with `Effect.try({ catch })` or explicit `mapError`.
|
||||
- Add service-level tests that assert the error tag and data, not just the HTTP
|
||||
status.
|
||||
|
||||
### 5. Declare HttpApi Errors Group By Group
|
||||
|
||||
For each HttpApi group:
|
||||
|
||||
- Inventory every service call and the typed errors it can return.
|
||||
- Add only the public error schemas that endpoint can actually emit.
|
||||
- Map service errors to HTTP errors in the handler file.
|
||||
- Keep built-in `HttpApiError` only for generic request/validation failures where
|
||||
the generated contract is accepted.
|
||||
- Update `httpapi/public.ts` compatibility transforms only when the generated
|
||||
spec cannot represent the desired source shape directly.
|
||||
- Regenerate the SDK after OpenAPI-visible changes and verify the diff is
|
||||
intentional.
|
||||
|
||||
Suggested route order:
|
||||
|
||||
1. `session` not-found and busy-state reads.
|
||||
2. `experimental` worktree mutations.
|
||||
3. `provider` auth and model selection errors.
|
||||
4. `mcp` OAuth and connection errors.
|
||||
5. Remaining route groups as typed error contracts are declared.
|
||||
|
||||
### 6. Remove Defect Recovery
|
||||
|
||||
After enough route groups declare their expected errors:
|
||||
|
||||
- Delete `catchDefect` recovery for domain errors.
|
||||
- Delete name-prefix checks such as `error.name.startsWith("Worktree")` from
|
||||
HTTP middleware.
|
||||
- Delete `NamedError` branches from the Effect HttpApi compatibility middleware
|
||||
once no Effect route depends on them.
|
||||
- Leave one final unknown-defect fallback that logs server-side and returns a
|
||||
safe generic `500` body.
|
||||
|
||||
## Inventory Checklist
|
||||
|
||||
Use this checklist when touching a service or route group.
|
||||
|
||||
- [ ] Does the service interface expose every expected failure in the Effect
|
||||
error type?
|
||||
- [ ] Are user-caused, provider-caused, IO, auth, missing-resource, and busy-state
|
||||
failures modeled as typed errors instead of defects?
|
||||
- [ ] Does the service avoid importing HTTP status, `HttpApiError`, or response
|
||||
classes?
|
||||
- [ ] Does the handler map each service error into a declared endpoint error?
|
||||
- [ ] Does the endpoint `error` field include every public error the handler can
|
||||
emit?
|
||||
- [ ] Does OpenAPI/SDK output either stay byte-identical or have an explicitly
|
||||
reviewed diff?
|
||||
- [ ] Do tests cover both service-level error typing and HTTP-level status/body?
|
||||
- [ ] Did the PR remove any now-unneeded case from the temporary compatibility
|
||||
middleware?
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
For service conversions:
|
||||
|
||||
- Test the service method directly with `testEffect(...)`.
|
||||
- Assert on `_tag` or class identity and the structured fields.
|
||||
- Avoid testing by string-matching `Cause.pretty(...)`.
|
||||
|
||||
For HttpApi conversions:
|
||||
|
||||
- Add or update the focused `test/server/httpapi-*.test.ts` file.
|
||||
- Assert status code, content type, and exact JSON body for declared public
|
||||
errors.
|
||||
- Add a regression test that the temporary middleware is no longer needed for the
|
||||
migrated route.
|
||||
- Keep compatibility tests aligned with the existing SDK contract until the
|
||||
public error shape intentionally changes.
|
||||
|
||||
## Verification Commands
|
||||
|
||||
Run from `packages/opencode` unless noted otherwise.
|
||||
|
||||
```bash
|
||||
bun run prettier --write <changed files>
|
||||
bunx oxlint <changed files>
|
||||
bun typecheck
|
||||
bun run test -- test/server/httpapi-session.test.ts
|
||||
```
|
||||
|
||||
Run SDK generation from the repo root when schemas or OpenAPI-visible errors
|
||||
change.
|
||||
|
||||
```bash
|
||||
./packages/sdk/js/script/build.ts
|
||||
```
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should legacy V1 routes keep `{ name, data }` forever while V2 routes expose a
|
||||
more Effect-native tagged error body?
|
||||
- Should storage not-found remain generic, or should callers map it to
|
||||
domain-specific not-found errors before crossing service boundaries?
|
||||
- Should `namedSchemaError(...)` stay as a long-term public-wire helper, or only
|
||||
as a migration bridge for old `NamedError` contracts?
|
||||
- Which SDK version boundary lets us stop remapping built-in Effect HttpApi error
|
||||
schemas in `httpapi/public.ts`?
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- New service code no longer uses `die` for expected failures.
|
||||
- A route reviewer can read an endpoint definition and see every public error it
|
||||
can return.
|
||||
- The temporary HttpApi error middleware shrinks over time instead of gaining new
|
||||
name-based cases.
|
||||
- Service tests prove domain error types without going through HTTP.
|
||||
- HTTP tests prove status/body contracts without relying on defect recovery.
|
||||
- [ ] Expected failures are typed errors, not defects.
|
||||
- [ ] Service method signatures expose the expected error union.
|
||||
- [ ] HTTP handlers translate domain errors at the boundary.
|
||||
- [ ] Public HTTP error bodies preserve existing wire contracts.
|
||||
- [ ] Generic middleware gets smaller or stays unchanged.
|
||||
- [ ] Focused tests cover the service error and any public HTTP response.
|
||||
|
||||
251
packages/opencode/specs/effect/guide.md
Normal file
251
packages/opencode/specs/effect/guide.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# Effect Guide
|
||||
|
||||
How we write Effect code in `packages/opencode`. The companion roadmap is
|
||||
[`todo.md`](./todo.md).
|
||||
|
||||
This guide describes the preferred shape for new work and migrations. If a
|
||||
legacy file differs, migrate it only when it is already in scope.
|
||||
|
||||
## Service Shape
|
||||
|
||||
Use one module per service: flat top-level exports, traced Effect methods,
|
||||
explicit layers, and a self-reexport at the bottom.
|
||||
|
||||
```ts
|
||||
export interface Interface {
|
||||
readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make<State>(Effect.fn("Foo.state")(() => Effect.succeed({})))
|
||||
|
||||
const get = Effect.fn("Foo.get")(function* (id: FooID) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* loadFoo(s, id)
|
||||
})
|
||||
|
||||
return Service.of({ get })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FooDep.defaultLayer))
|
||||
|
||||
export * as Foo from "./foo"
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Do not use `export namespace Foo { ... }`.
|
||||
- Use `Effect.fn("Foo.method")` for public service methods.
|
||||
- Use `Effect.fnUntraced` for small internal helpers that do not need a
|
||||
span.
|
||||
- Keep helpers as non-exported top-level declarations in the same file.
|
||||
- Self-reexport with `export * as Foo from "."` for `index.ts`, otherwise
|
||||
`export * as Foo from "./foo"`.
|
||||
- In `src/config`, keep the existing top-of-file self-export pattern.
|
||||
|
||||
## Runtime Boundaries
|
||||
|
||||
Most code should run through [`AppRuntime`](../../src/effect/app-runtime.ts).
|
||||
It hosts `AppLayer`, shares the global `memoMap`, and restores the current
|
||||
instance/workspace refs when crossing from non-Effect code.
|
||||
|
||||
Use `AppRuntime.runPromise(effect)` at app boundaries such as CLI commands,
|
||||
HTTP handlers, or plain async adapters.
|
||||
|
||||
`makeRuntime(...)` still exists for a few intentional service-local
|
||||
boundaries and migration leftovers. Do not add a new service-local runtime
|
||||
unless the service truly cannot live in `AppLayer`.
|
||||
|
||||
## Runtime Flags
|
||||
|
||||
Read opencode runtime flags through
|
||||
[`RuntimeFlags.Service`](../../src/effect/runtime-flags.ts), not through
|
||||
mutable `Flag` or late `process.env` reads.
|
||||
|
||||
Tests should vary behavior with explicit layer variants:
|
||||
|
||||
```ts
|
||||
const it = testEffect(MyService.defaultLayer.pipe(Layer.provide(RuntimeFlags.layer({ experimentalScout: true }))))
|
||||
```
|
||||
|
||||
Do not mutate `process.env` or `Flag` after services/layers are built.
|
||||
|
||||
## Per-Instance State
|
||||
|
||||
Use [`InstanceState`](../../src/effect/instance-state.ts) when two open
|
||||
directories should not share one copy of a service's state. It is backed by
|
||||
a `ScopedCache`, keyed by directory, and disposed automatically when an
|
||||
instance is unloaded.
|
||||
|
||||
Put subscriptions, finalizers, and scoped background work inside the
|
||||
`InstanceState.make(...)` initializer:
|
||||
|
||||
```ts
|
||||
const cache =
|
||||
yield *
|
||||
InstanceState.make<State>(
|
||||
Effect.fn("Foo.state")(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
yield* bus.subscribeAll().pipe(
|
||||
Stream.runForEach((event) => handleEvent(event)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
yield* Effect.acquireRelease(openResource, closeResource)
|
||||
|
||||
return yield* loadInitialState()
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Do not add separate `started` flags on top of `InstanceState`. Let
|
||||
`ScopedCache` handle run-once and deduplication.
|
||||
|
||||
To make `init()` non-blocking, fork at the caller/bootstrap boundary. Do
|
||||
not fork inside `InstanceState.make(...)` just to return early with
|
||||
partially initialized state.
|
||||
|
||||
## Errors
|
||||
|
||||
Expected domain failures belong on the Effect error channel. Defects are
|
||||
for bugs, impossible states, and final unknown-boundary fallbacks.
|
||||
|
||||
```ts
|
||||
export class SessionBusyError extends Schema.TaggedErrorClass<SessionBusyError>()("SessionBusyError", {
|
||||
sessionID: SessionID,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export type Error = Storage.Error | SessionBusyError
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (id: SessionID) => Effect.Effect<Info, Error>
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Use `Schema.TaggedErrorClass` for new expected domain errors.
|
||||
- Export a domain-level `Error` union from service modules.
|
||||
- In `Effect.gen` / `Effect.fn`, prefer `yield* new MyError(...)` for
|
||||
direct expected failures.
|
||||
- Use `Schema.Defect` for unknown cause fields.
|
||||
- Use `Effect.try(...)`, `Effect.tryPromise(...)`, `Effect.mapError`,
|
||||
`Effect.catchTag`, and `Effect.catchTags` to translate external
|
||||
failures into domain errors.
|
||||
- Do not use `Effect.die(...)` for user, IO, validation, missing-resource,
|
||||
auth, provider, or busy-state failures.
|
||||
|
||||
## HTTP Error Boundaries
|
||||
|
||||
Service modules stay HTTP-agnostic. They should not import HTTP status
|
||||
codes, `HttpApiError`, `HttpServerResponse`, or route-specific error
|
||||
schemas.
|
||||
|
||||
HTTP handlers translate service errors into endpoint-declared public error
|
||||
schemas. Keep mappings inline when they are one-off; extract tiny shared
|
||||
helpers only when the same translation repeats.
|
||||
|
||||
Do not turn generic middleware into a registry of domain errors. Middleware
|
||||
should handle cross-cutting concerns and the final unknown-defect fallback.
|
||||
|
||||
Preserve legacy public wire shapes, such as `{ name, data }`, until a
|
||||
deliberate breaking API change.
|
||||
|
||||
## Schemas
|
||||
|
||||
Use Effect Schema as the source of truth.
|
||||
|
||||
- Use `Schema.Class` for exported data objects with a clear identity.
|
||||
- Use `Schema.Struct` for local shapes and simple nested objects.
|
||||
- Use `Schema.brand` for single-value IDs.
|
||||
- Reuse named refinements instead of re-spelling constraints.
|
||||
- Prefer narrow boundary helpers over generic Schema-to-Zod bridges.
|
||||
|
||||
Intentional boundaries:
|
||||
|
||||
- Public plugin tools still expose Zod through `tool.schema = z`.
|
||||
- Tool parameter JSON Schema is generated through tool-specific helpers.
|
||||
- Public config and TUI schemas are generated through the schema script.
|
||||
|
||||
## Preferred Services
|
||||
|
||||
In effectified code, yield existing services instead of dropping to ad hoc
|
||||
platform APIs.
|
||||
|
||||
- Use `AppFileSystem.Service` instead of raw `fs/promises` for app file IO.
|
||||
- Use `AppProcess.Service` instead of direct `ChildProcessSpawner.spawn` or
|
||||
legacy process helpers.
|
||||
- Use `HttpClient.HttpClient` instead of raw `fetch` inside Effect code.
|
||||
- Use `Path.Path`, `Config`, `Clock`, and `DateTime` when already inside
|
||||
Effect.
|
||||
- Use `Effect.callback` for callback-based APIs.
|
||||
- Use `Effect.void` instead of `Effect.succeed(undefined)`.
|
||||
- Use `Effect.cached` when concurrent callers should share one in-flight
|
||||
computation.
|
||||
|
||||
For background loops, use `Effect.repeat` or `Effect.schedule` with
|
||||
`Effect.forkScoped` in the owning layer/state scope.
|
||||
|
||||
## Promise And ALS Bridges
|
||||
|
||||
[`EffectBridge`](../../src/effect/bridge.ts) is the sanctioned helper for
|
||||
Promise/callback interop that needs to preserve instance/workspace context.
|
||||
Keep it, but reduce its dependency on legacy `Instance.current` /
|
||||
`Instance.restore` over time.
|
||||
|
||||
`Instance.bind` / `Instance.restore` are transitional legacy tools. Use
|
||||
them only for native callbacks that still require legacy ALS context. Do
|
||||
not use them for `setTimeout`, `Promise.then`, `EventEmitter.on`, or
|
||||
Effect fibers.
|
||||
|
||||
## Testing
|
||||
|
||||
Detailed test migration rules live in
|
||||
[`test/EFFECT_TEST_MIGRATION.md`](../../test/EFFECT_TEST_MIGRATION.md).
|
||||
|
||||
Core pattern:
|
||||
|
||||
```ts
|
||||
const it = testEffect(Layer.mergeAll(MyService.defaultLayer))
|
||||
|
||||
describe("my service", () => {
|
||||
it.instance("does the thing", () =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* MyService.Service
|
||||
expect(yield* svc.run()).toEqual("ok")
|
||||
}),
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Use `it.effect(...)` for TestClock/TestConsole tests.
|
||||
- Use `it.live(...)` for real timers, filesystem mtimes, child processes,
|
||||
git, locks, or other live integration behavior.
|
||||
- Use `it.instance(...)` for service tests that need a scoped instance.
|
||||
- Prefer Effect-aware fixtures from `test/fixture/fixture.ts`.
|
||||
- Avoid sleeps; wait for real events or deterministic state transitions.
|
||||
- Avoid mutable `process.env`, `Flag`, or module-global changes after
|
||||
layers are built.
|
||||
- Use `Layer.mock` for partial service stubs.
|
||||
- Avoid custom `ManagedRuntime`, `attach(...)`, or ad hoc `run(...)` test
|
||||
wrappers.
|
||||
|
||||
## Verification
|
||||
|
||||
From `packages/opencode`:
|
||||
|
||||
```bash
|
||||
bun run typecheck
|
||||
bun run test -- path/to/test.ts
|
||||
```
|
||||
|
||||
Do not run tests from the repo root; the repo has a guard for that.
|
||||
@@ -1,291 +1,62 @@
|
||||
# Effect patterns
|
||||
# Effect Migration Patterns
|
||||
|
||||
Practical reference for new and migrated Effect code in `packages/opencode`.
|
||||
This is the compact reference for moving code toward the current Effect
|
||||
shape. The high-level roadmap is [`todo.md`](./todo.md); examples and
|
||||
rules are in [`guide.md`](./guide.md).
|
||||
|
||||
## Choose scope
|
||||
## Default Shape
|
||||
|
||||
Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need per-directory state, per-instance cleanup, or project-bound background work. InstanceState uses a `ScopedCache` keyed by directory, so each open project gets its own copy of the state that is automatically cleaned up on disposal.
|
||||
- Service methods return `Effect`.
|
||||
- Service methods are named with `Effect.fn("Domain.method")`.
|
||||
- Expected failures are typed errors on the error channel.
|
||||
- Dependencies are yielded once at layer construction and closed over by
|
||||
methods.
|
||||
- `defaultLayer` wires production dependencies; tests can use open layers
|
||||
when replacing dependencies.
|
||||
|
||||
Use `makeRuntime` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. Returns `{ runPromise, runFork, runCallback }`.
|
||||
## Instance State
|
||||
|
||||
- Global services (no per-directory state): Account, Auth, AppFileSystem, Installation, Truncate, Worktree
|
||||
- Instance-scoped (per-directory state via InstanceState): Agent, Bus, Command, Config, File, FileWatcher, Format, LSP, MCP, Permission, Plugin, ProviderAuth, Pty, Question, SessionStatus, Skill, Snapshot, ToolRegistry, Vcs
|
||||
Use `InstanceState` for per-directory state, subscriptions, scoped
|
||||
background work, and per-instance cleanup.
|
||||
|
||||
Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`.
|
||||
Do not add ad hoc `started` flags on top of `InstanceState`; the scoped
|
||||
cache handles run-once and concurrent deduplication.
|
||||
|
||||
## Instance context transition
|
||||
## Runtime Boundaries
|
||||
|
||||
See `instance-context.md` for the phased plan to remove the legacy ALS / promise-backed `Instance` helper and move request / CLI / tool boundaries onto Effect-provided instance scope.
|
||||
Prefer `AppRuntime` for crossing from non-Effect code into the shared app
|
||||
layer.
|
||||
|
||||
## Service shape
|
||||
`makeRuntime(...)` exists for intentional service-local boundaries and
|
||||
legacy facades. Do not add new service-local runtimes unless the service is
|
||||
genuinely outside `AppLayer`.
|
||||
|
||||
Every service follows the same pattern: one module, flat top-level exports, traced Effect methods, and a self-reexport at the bottom when the file is the public module.
|
||||
## Platform Edges
|
||||
|
||||
```ts
|
||||
export interface Interface {
|
||||
readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
|
||||
}
|
||||
- Use `AppFileSystem.Service` instead of raw filesystem APIs in
|
||||
effectified services.
|
||||
- Use `AppProcess.Service` instead of raw process wrappers.
|
||||
- Use `HttpClient.HttpClient` instead of raw `fetch` in Effect code.
|
||||
- Use `Effect.cached` for shared in-flight work.
|
||||
- Use `Effect.callback` for callback APIs.
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
|
||||
## Tests During Migration
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
|
||||
)
|
||||
When migrating code, migrate touched tests toward
|
||||
[`test/EFFECT_TEST_MIGRATION.md`](../../test/EFFECT_TEST_MIGRATION.md):
|
||||
|
||||
const get = Effect.fn("Foo.get")(function* (id: FooID) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
// ...
|
||||
})
|
||||
- `testEffect(...)`
|
||||
- `it.effect`, `it.live`, or `it.instance`
|
||||
- explicit layers for behavior changes
|
||||
- deterministic waits instead of sleeps
|
||||
- no mutable env/global flags after layers are built
|
||||
|
||||
return Service.of({ get })
|
||||
}),
|
||||
)
|
||||
## Migration Checklist
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
|
||||
|
||||
export * as Foo from "."
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Keep the service surface in one module; prefer flat top-level exports over `export namespace Foo { ... }`
|
||||
- Use `Effect.fn("Foo.method")` for Effect methods
|
||||
- Use a self-reexport (`export * as Foo from "."` or `"./foo"`) for the public namespace projection
|
||||
- Avoid service-local `makeRuntime(...)` facades unless a file is still intentionally in the older migration phase
|
||||
- No `Layer.fresh` for normal per-directory isolation; use `InstanceState`
|
||||
|
||||
## Schema boundaries
|
||||
|
||||
Use Effect Schema directly at HTTP, tool, and AI SDK boundaries. For provider-facing JSON Schema, use a boundary-specific helper such as `ToolJsonSchema.fromSchema(...)`; do not reintroduce generic Effect Schema → Zod conversion.
|
||||
|
||||
## InstanceState init patterns
|
||||
|
||||
The `InstanceState.make` init callback receives a `Scope`, so you can use `Effect.acquireRelease`, `Effect.addFinalizer`, and `Effect.forkScoped` inside it. Resources acquired this way are automatically cleaned up when the instance is disposed or invalidated by `ScopedCache`. This makes it the right place for:
|
||||
|
||||
- **Subscriptions**: Yield `Bus.Service` at the layer level, then use `Stream` + `forkScoped` inside the init closure. The fiber is automatically interrupted when the instance scope closes:
|
||||
|
||||
```ts
|
||||
const bus = yield * Bus.Service
|
||||
|
||||
const cache =
|
||||
yield *
|
||||
InstanceState.make<State>(
|
||||
Effect.fn("Foo.state")(function* (ctx) {
|
||||
// ... load state ...
|
||||
|
||||
yield* bus.subscribeAll().pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.sync(() => {
|
||||
/* handle */
|
||||
}),
|
||||
),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return {
|
||||
/* state */
|
||||
}
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
- **Resource cleanup**: Use `Effect.acquireRelease` or `Effect.addFinalizer` for resources that need teardown (native watchers, process handles, etc.):
|
||||
|
||||
```ts
|
||||
yield *
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() => nativeAddon.watch(dir)),
|
||||
(watcher) => Effect.sync(() => watcher.close()),
|
||||
)
|
||||
```
|
||||
|
||||
- **Background fibers**: Use `Effect.forkScoped` — the fiber is interrupted on disposal.
|
||||
- **Side effects at init**: Config notification, event wiring, etc. all belong in the init closure. Callers just do `InstanceState.get(cache)` to trigger everything, and `ScopedCache` deduplicates automatically.
|
||||
|
||||
The key insight: don't split init into a separate method with a `started` flag. Put everything in the `InstanceState.make` closure and let `ScopedCache` handle the run-once semantics.
|
||||
|
||||
## Effect.cached for deduplication
|
||||
|
||||
Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation. It memoizes the result and deduplicates concurrent fibers — second caller joins the first caller's fiber instead of starting a new one.
|
||||
|
||||
```ts
|
||||
// Inside the layer — yield* to initialize the memo
|
||||
let cached = yield * Effect.cached(loadExpensive())
|
||||
|
||||
const get = Effect.fn("Foo.get")(function* () {
|
||||
return yield* cached // concurrent callers share the same fiber
|
||||
})
|
||||
|
||||
// To invalidate: swap in a fresh memo
|
||||
const invalidate = Effect.fn("Foo.invalidate")(function* () {
|
||||
cached = yield* Effect.cached(loadExpensive())
|
||||
})
|
||||
```
|
||||
|
||||
Prefer `Effect.cached` over these patterns:
|
||||
|
||||
- Storing a `Fiber.Fiber | undefined` with manual check-and-fork (e.g. `file/index.ts` `ensure`)
|
||||
- Storing a `Promise<void>` task for deduplication (e.g. `skill/index.ts` `ensure`)
|
||||
- `let cached: X | undefined` with check-and-load (races when two callers see `undefined` before either resolves)
|
||||
|
||||
`Effect.cached` handles the run-once + concurrent-join semantics automatically. For invalidatable caches, reassign with `yield* Effect.cached(...)` — the old memo is discarded.
|
||||
|
||||
## 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
|
||||
|
||||
Service-shape migrated (single namespace, traced methods, `InstanceState` where needed).
|
||||
|
||||
This checklist is only about the service shape migration. Many of these services still keep `makeRuntime(...)` plus async facade exports; that facade-removal phase is tracked separately in `facades.md`.
|
||||
|
||||
- [x] `Account` — `account/index.ts`
|
||||
- [x] `Agent` — `agent/agent.ts`
|
||||
- [x] `AppFileSystem` — `filesystem/index.ts`
|
||||
- [x] `Auth` — `auth/index.ts` (uses `zod()` helper for Schema→Zod interop)
|
||||
- [x] `Bus` — `bus/index.ts`
|
||||
- [x] `Command` — `command/index.ts`
|
||||
- [x] `Config` — `config/config.ts`
|
||||
- [x] `Discovery` — `skill/discovery.ts` (dependency-only layer, no standalone runtime)
|
||||
- [x] `File` — `file/index.ts`
|
||||
- [x] `FileWatcher` — `file/watcher.ts`
|
||||
- [x] `Format` — `format/index.ts`
|
||||
- [x] `Installation` — `installation/index.ts`
|
||||
- [x] `LSP` — `lsp/index.ts`
|
||||
- [x] `MCP` — `mcp/index.ts`
|
||||
- [x] `McpAuth` — `mcp/auth.ts`
|
||||
- [x] `Permission` — `permission/index.ts`
|
||||
- [x] `Plugin` — `plugin/index.ts`
|
||||
- [x] `Project` — `project/project.ts`
|
||||
- [x] `ProviderAuth` — `provider/auth.ts`
|
||||
- [x] `Pty` — `pty/index.ts`
|
||||
- [x] `Question` — `question/index.ts`
|
||||
- [x] `SessionStatus` — `session/status.ts`
|
||||
- [x] `Skill` — `skill/index.ts`
|
||||
- [x] `Snapshot` — `snapshot/index.ts`
|
||||
- [x] `ToolRegistry` — `tool/registry.ts`
|
||||
- [x] `Truncate` — `tool/truncate.ts`
|
||||
- [x] `Vcs` — `project/vcs.ts`
|
||||
- [x] `Worktree` — `worktree/index.ts`
|
||||
|
||||
- [x] `Session` — `session/index.ts`
|
||||
- [x] `SessionProcessor` — `session/processor.ts`
|
||||
- [x] `SessionPrompt` — `session/prompt.ts`
|
||||
- [x] `SessionCompaction` — `session/compaction.ts`
|
||||
- [x] `SessionSummary` — `session/summary.ts`
|
||||
- [x] `SessionRevert` — `session/revert.ts`
|
||||
- [x] `Instruction` — `session/instruction.ts`
|
||||
- [x] `SystemPrompt` — `session/system.ts`
|
||||
- [x] `Provider` — `provider/provider.ts`
|
||||
- [x] `Storage` — `storage/storage.ts`
|
||||
- [x] `ShareNext` — `share/share-next.ts`
|
||||
- [x] `SessionTodo` — `session/todo.ts`
|
||||
|
||||
Still open at the service-shape level:
|
||||
|
||||
- [ ] `SyncEvent` — `sync/index.ts` (deferred pending sync with James)
|
||||
- [ ] `Workspace` — `control-plane/workspace.ts` (deferred pending sync with James)
|
||||
|
||||
## Tool migration
|
||||
|
||||
Tool-specific migration guidance and checklist live in `tools.md`.
|
||||
|
||||
## Effect service adoption in already-migrated code
|
||||
|
||||
Some already-effectified areas still use raw `Filesystem.*` or `Process.spawn` in their implementation or helper modules. These are low-hanging fruit — the layers already exist, they just need the dependency swap.
|
||||
|
||||
### `Filesystem.*` → `AppFileSystem.Service` (yield in layer)
|
||||
|
||||
- [x] `config/config.ts` — `installDependencies()` now uses `AppFileSystem`
|
||||
- [x] `provider/provider.ts` — recent model state now reads via `AppFileSystem.Service`
|
||||
|
||||
### `Process.spawn` → `ChildProcessSpawner` (yield in layer)
|
||||
|
||||
- [x] `format/formatter.ts` — direct `Process.spawn()` checks removed (`air`, `uv`)
|
||||
- [ ] `lsp/server.ts` — multiple `Process.spawn()` installs/download helpers
|
||||
|
||||
## Filesystem consolidation
|
||||
|
||||
`util/filesystem.ts` is still used widely across `src/`, and raw `fs` / `fs/promises` imports still exist in multiple tooling and infrastructure files. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` where possible — this should happen naturally during each migration, not as a separate sweep.
|
||||
|
||||
Tool-specific filesystem cleanup notes live in `tools.md`.
|
||||
|
||||
## Primitives & utilities
|
||||
|
||||
- [ ] `util/lock.ts` — reader-writer lock → Effect Semaphore/Permit
|
||||
- [ ] `util/flock.ts` — file-based distributed lock with heartbeat → Effect.repeat + addFinalizer
|
||||
- [ ] `util/process.ts` — child process spawn wrapper → return Effect instead of Promise
|
||||
- [ ] `util/lazy.ts` — replace uses in Effect code with Effect.cached; keep for sync-only code
|
||||
|
||||
## Destroying the facades
|
||||
|
||||
This phase is no longer broadly open. There are 5 `makeRuntime(...)` call sites under `src/`, and only a small subset are still ordinary facade-removal targets. The live checklist now lives in `facades.md`.
|
||||
|
||||
These facades exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
|
||||
|
||||
### Process
|
||||
|
||||
For each service, the migration is roughly:
|
||||
|
||||
1. **Find callers.** `grep -n "Namespace\.(methodA|methodB|...)"` across `src/` and `test/`. Skip the service file itself.
|
||||
2. **Migrate production callers.** For each effectful caller that does `Effect.tryPromise(() => Namespace.method(...))`:
|
||||
- Add the service to the caller's layer R type (`Layer.Layer<Self, never, ... | Namespace.Service>`)
|
||||
- Yield it at the top of the layer: `const ns = yield* Namespace.Service`
|
||||
- Replace `Effect.tryPromise(() => Namespace.method(...))` with `yield* ns.method(...)` (or `ns.method(...).pipe(Effect.orElseSucceed(...))` for the common fallback case)
|
||||
- Add `Layer.provide(Namespace.defaultLayer)` to the caller's own `defaultLayer` chain
|
||||
3. **Fix tests that used the caller's raw `.layer`.** Any test that composes `Caller.layer` (not `defaultLayer`) needs to also provide the newly-required service tag. The fastest fix is usually switching to `Caller.defaultLayer` since it now pulls in the new dependency.
|
||||
4. **Migrate test callers of the facade.** Tests calling `Namespace.method(...)` directly get converted to full effectful style using `testEffect(Namespace.defaultLayer)` + `it.live` / `it.effect` + `yield* svc.method(...)`. Don't wrap the test body in `Effect.promise(async () => {...})` — do the whole thing in `Effect.gen` and use `AppFileSystem.Service` / `tmpdirScoped` / `Effect.addFinalizer` for what used to be raw `fs` / `Bun.write` / `try/finally`.
|
||||
5. **Delete the facades.** Once `grep` shows zero callers, remove the `export async function` block AND the `makeRuntime(...)` line from the service namespace. Also remove the now-unused `import { makeRuntime }`.
|
||||
|
||||
### Pitfalls
|
||||
|
||||
- **Layer caching inside tests.** `testEffect(layer)` constructs the Storage (or whatever) service once and memoizes it. If a test then tries `inner.pipe(Effect.provide(customStorage))` to swap in a differently-configured Storage, the outer cached one wins and the inner provision is a no-op. Fix: wrap the overriding layer in `Layer.fresh(...)`, which forces a new instance to be built instead of hitting the memoMap cache. This lets a single `testEffect(...)` serve both simple and per-test-customized cases.
|
||||
- **`Effect.tryPromise` → `yield*` drops the Promise layer.** The old code was `Effect.tryPromise(() => Storage.read(...))` — a `tryPromise` wrapper because the facade returned a Promise. The new code is `yield* storage.read(...)` directly — the service method already returns an Effect, so no wrapper is needed. Don't reach for `Effect.promise` or `Effect.tryPromise` during migration; if you're using them on a service method call, you're doing it wrong.
|
||||
- **Raw `.layer` test callers break silently in the type checker.** When you add a new R requirement to a service's `.layer`, any test that composes it raw (not `defaultLayer`) becomes under-specified. `tsgo` will flag this — the error looks like `Type 'Storage.Service' is not assignable to type '... | Service | TestConsole'`. Usually the fix is to switch that composition to `defaultLayer`, or add `Layer.provide(NewDep.defaultLayer)` to the custom composition.
|
||||
- **Tests that do async setup with `fs`, `Bun.write`, `tmpdir`.** Convert these to `AppFileSystem.Service` calls inside `Effect.gen`, and use `tmpdirScoped()` instead of `tmpdir()` so cleanup happens via the scope finalizer. For file operations on the actual filesystem (not via a service), a small helper like `const writeJson = Effect.fnUntraced(function* (file, value) { const fs = yield* AppFileSystem.Service; yield* fs.makeDirectory(path.dirname(file), { recursive: true }); yield* fs.writeFileString(file, JSON.stringify(value, null, 2)) })` keeps the migration tests clean.
|
||||
|
||||
### Migration log
|
||||
|
||||
- `SessionStatus` — migrated 2026-04-11. Replaced the last route and retry-policy callers with `AppRuntime.runPromise(SessionStatus.Service.use(...))` and removed the `makeRuntime(...)` facade.
|
||||
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
|
||||
- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
|
||||
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
|
||||
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/routes/instance/session.ts` converted; facade removed.
|
||||
- `Account` — migrated 2026-04-11. Callers in `server/routes/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
|
||||
- `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
|
||||
- `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
|
||||
- `Question` — migrated 2026-04-11. Callers in `server/routes/instance/question.ts` and test converted; facade removed.
|
||||
- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
|
||||
|
||||
## Route handler effectification
|
||||
|
||||
Route-handler migration guidance and checklist live in `routes.md`.
|
||||
- [ ] The code has a single Effect body instead of Promise wrappers around
|
||||
service calls.
|
||||
- [ ] Expected failures are typed errors, not thrown exceptions or defects.
|
||||
- [ ] Layer requirements are explicit.
|
||||
- [ ] Tests use Effect-aware fixtures and focused layers.
|
||||
- [ ] Public behavior and wire shapes are preserved unless intentionally
|
||||
changed.
|
||||
|
||||
@@ -1,57 +1,61 @@
|
||||
# Route handler effectification
|
||||
# HTTP Route Patterns
|
||||
|
||||
Practical reference for converting server route handlers in `packages/opencode` to a single `AppRuntime.runPromise(Effect.gen(...))` body.
|
||||
Current guidance for `packages/opencode/src/server/routes/instance/httpapi`.
|
||||
|
||||
## Goal
|
||||
## Handler Shape
|
||||
|
||||
Route handlers should wrap their entire body in a single `AppRuntime.runPromise(Effect.gen(...))` call, yielding services from context rather than calling facades one-by-one.
|
||||
|
||||
This eliminates multiple `runPromise` round-trips and lets handlers compose naturally.
|
||||
Use `HttpApiBuilder.group(...)` for normal JSON and streaming HTTP API
|
||||
endpoints. Yield stable services once while building the handler layer,
|
||||
then close over those services in endpoint implementations.
|
||||
|
||||
```ts
|
||||
// Before - one facade call per service
|
||||
;async (c) => {
|
||||
await SessionRunState.assertNotBusy(id)
|
||||
await Session.removeMessage({ sessionID: id, messageID })
|
||||
return c.json(true)
|
||||
}
|
||||
export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
|
||||
// After - one Effect.gen, yield services from context
|
||||
;async (c) => {
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* SessionRunState.Service
|
||||
const session = yield* Session.Service
|
||||
yield* state.assertNotBusy(id)
|
||||
yield* session.removeMessage({ sessionID: id, messageID })
|
||||
}),
|
||||
)
|
||||
return c.json(true)
|
||||
}
|
||||
return handlers.handle("list", () => session.list())
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
## Rules
|
||||
Use raw `HttpRouter` only for routes that do not fit the request/response
|
||||
HttpApi model, such as WebSocket upgrades or catch-all fallback routes.
|
||||
|
||||
- Wrap the whole handler body in one `AppRuntime.runPromise(Effect.gen(...))` call when the handler is service-heavy.
|
||||
- Yield services from context instead of calling async facades repeatedly.
|
||||
- When independent service calls can run in parallel, use `Effect.all(..., { concurrency: "unbounded" })`.
|
||||
- Prefer one composed Effect body over multiple separate `runPromise(...)` calls in the same handler.
|
||||
Do not rebuild stable layers inside request handlers. Provide stable
|
||||
services at the route/layer boundary and use request-level provisioning
|
||||
only for request-derived context.
|
||||
|
||||
## Current route files
|
||||
## Error Boundaries
|
||||
|
||||
Current instance route files live under `src/server/routes/instance/httpapi`.
|
||||
Most handlers already yield stable services at route-layer construction and then
|
||||
close over those services in endpoint implementations.
|
||||
Expected service errors should be mapped at the handler boundary to
|
||||
endpoint-declared public HTTP errors. Keep one-off mappings inline. Extract
|
||||
small helpers when the same mapping repeats.
|
||||
|
||||
Files still worth tracking here:
|
||||
Generic middleware should not become a domain-error mapper. It should
|
||||
handle cross-cutting concerns and final unknown-defect fallback.
|
||||
|
||||
- [ ] `handlers/session.ts` — still the heaviest mixed file; some paths keep compatibility translations and direct event publication
|
||||
- [ ] `handlers/experimental.ts` — mixed state; some handlers still rely on request-local context reads
|
||||
- [ ] `middleware/*` — still contains compatibility policy for auth, compression, errors, instance context, and workspace routing
|
||||
- [ ] `public.ts` — still owns SDK/OpenAPI compatibility translation shims
|
||||
- [ ] raw route modules — WebSocket and catch-all routes should stay explicit and avoid rebuilding stable layers per request
|
||||
Public JSON errors should be explicit schema contracts declared on each
|
||||
endpoint or group. Built-in `HttpApiError.*` is fine only when its generated
|
||||
body is intentionally the public wire shape.
|
||||
|
||||
## Notes
|
||||
Preserve existing `{ name, data }` error bodies until a deliberate breaking
|
||||
API change.
|
||||
|
||||
- Route conversion is now less about backend migration and more about removing the remaining direct `Instance.*` reads, request-local service plumbing, and OpenAPI compatibility shims.
|
||||
- Prefer route-layer service capture over rebuilding or providing stable layers inside individual handlers.
|
||||
## OpenAPI Compatibility
|
||||
|
||||
`public.ts` still owns SDK/OpenAPI compatibility transforms. Shrink those
|
||||
transforms by tightening source schemas one workaround at a time.
|
||||
|
||||
When an OpenAPI-visible source schema changes:
|
||||
|
||||
- verify the generated SDK diff is intentional
|
||||
- preserve legacy compatibility unless the PR explicitly changes it
|
||||
- prefer source-schema fixes over new post-processing rules
|
||||
|
||||
## Checklist For Route PRs
|
||||
|
||||
- [ ] Stable services are yielded at handler-layer construction.
|
||||
- [ ] Expected domain errors are translated at the route boundary.
|
||||
- [ ] Endpoint/group error schemas describe the public body and status.
|
||||
- [ ] Middleware does not gain new domain-specific name checks.
|
||||
- [ ] Raw routes are used only when HttpApi is the wrong abstraction.
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
# Schema migration
|
||||
# Schema Migration
|
||||
|
||||
Practical reference for migrating data types in `packages/opencode` from
|
||||
Zod-first definitions to Effect Schema.
|
||||
Use Effect Schema as the source of truth for domain models, DTOs, IDs,
|
||||
inputs, outputs, and typed errors.
|
||||
|
||||
## Goal
|
||||
This is guidance, not an inventory. Do not use this file to track which
|
||||
schema modules are complete; verify current state with `git grep` before
|
||||
starting a migration.
|
||||
|
||||
Use Effect Schema as the source of truth for domain models, IDs, inputs,
|
||||
outputs, and typed errors. Prefer native Effect Schema, Standard Schema, and
|
||||
native JSON Schema generation at HTTP, tool, and AI SDK boundaries.
|
||||
## Preferred Shapes
|
||||
|
||||
The long-term driver is `specs/effect/http-api.md`: Schema-first DTOs should
|
||||
flow through `HttpApi` / `HttpRouter` without a Zod translation layer.
|
||||
|
||||
## Preferred shapes
|
||||
|
||||
### Data objects
|
||||
|
||||
Use `Schema.Class` for structured data.
|
||||
Use `Schema.Class` for exported data objects with a clear domain identity:
|
||||
|
||||
```ts
|
||||
export class Info extends Schema.Class<Info>("Foo.Info")({
|
||||
@@ -26,18 +19,16 @@ export class Info extends Schema.Class<Info>("Foo.Info")({
|
||||
}) {}
|
||||
```
|
||||
|
||||
If a schema needs local static helpers, use the two-step `withStatics` pattern:
|
||||
Use `Schema.Struct` for local shapes and simple nested objects:
|
||||
|
||||
```ts
|
||||
export const Info = Schema.Struct({
|
||||
const Payload = Schema.Struct({
|
||||
id: FooID,
|
||||
name: Schema.String,
|
||||
}).pipe(withStatics((s) => ({ decode: Schema.decodeUnknownOption(s) })))
|
||||
value: Schema.String,
|
||||
})
|
||||
```
|
||||
|
||||
### Errors
|
||||
|
||||
Use `Schema.TaggedErrorClass` for domain errors.
|
||||
Use `Schema.TaggedErrorClass` for expected domain errors:
|
||||
|
||||
```ts
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("FooNotFoundError", {
|
||||
@@ -45,309 +36,53 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Foo
|
||||
}) {}
|
||||
```
|
||||
|
||||
### IDs and branded leaf types
|
||||
Use branded schema-backed IDs for single-value domain identifiers.
|
||||
|
||||
Keep branded/schema-backed IDs as Effect schemas.
|
||||
## Boundary Rule
|
||||
|
||||
### Refinements
|
||||
Effect Schema should own the type. Boundaries should consume Effect Schema
|
||||
directly or use narrow boundary-specific helpers. Avoid reintroducing a
|
||||
generic Effect Schema -> Zod bridge.
|
||||
|
||||
Reuse named refinements instead of re-spelling numeric or string constraints in
|
||||
every schema. Boundary JSON Schema helpers should normalize native Effect JSON
|
||||
Schema output only where a provider requires it.
|
||||
Current intentional boundaries:
|
||||
|
||||
- Public plugin tools still expose Zod through `tool.schema = z`.
|
||||
- Tool parameters use tool-specific JSON Schema helpers.
|
||||
- Public config and TUI schema generation goes through the schema script.
|
||||
- AI SDK object generation uses Standard Schema / JSON Schema helpers.
|
||||
|
||||
When Zod must stay temporarily, leave a short note explaining the boundary
|
||||
or compatibility reason.
|
||||
|
||||
## Refinements
|
||||
|
||||
Reuse named refinements instead of re-spelling constraints:
|
||||
|
||||
```ts
|
||||
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
|
||||
const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))
|
||||
const HexColor = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
|
||||
```
|
||||
|
||||
## Compatibility rule
|
||||
Prefer domain-named leaf schemas when the name improves callers or error
|
||||
messages. Avoid adding brands purely for novelty.
|
||||
|
||||
During migration, route validators, tool parameters, and AI SDK schemas should
|
||||
consume Effect schemas directly or use a narrow boundary helper. Avoid
|
||||
maintaining a second hand-written Zod schema.
|
||||
## Migration Order
|
||||
|
||||
The default should be:
|
||||
For a domain that still has mixed schemas:
|
||||
|
||||
- Effect Schema owns the type
|
||||
- new domain models should not start Zod-first unless there is a concrete
|
||||
boundary-specific need
|
||||
1. Shared leaf models and branded IDs.
|
||||
2. Exported `Info`, `Input`, `Output`, and event payload types.
|
||||
3. Expected domain errors.
|
||||
4. Service-local internal models.
|
||||
5. HTTP/tool/AI boundary validators.
|
||||
|
||||
## When Zod can stay
|
||||
Keep public wire shapes stable unless the PR is explicitly a breaking API
|
||||
change.
|
||||
|
||||
It is fine to keep a Zod-native schema temporarily when:
|
||||
## Checklist For A PR
|
||||
|
||||
- the type is only used at an HTTP or tool boundary and is not reused elsewhere
|
||||
- the validator is part of an existing public API that explicitly accepts Zod
|
||||
- the migration would force unrelated churn across a large call graph
|
||||
|
||||
When this happens, prefer leaving a short note or TODO rather than silently
|
||||
creating a parallel schema source of truth.
|
||||
|
||||
## Boundary helpers
|
||||
|
||||
Use narrow helpers at concrete boundaries instead of a generic Schema → Zod bridge.
|
||||
|
||||
- Tool parameters: `ToolJsonSchema.fromSchema(...)` and `ToolJsonSchema.fromTool(...)`
|
||||
- Public config/TUI schemas: `packages/opencode/script/schema.ts`
|
||||
- AI SDK object generation: `Schema.toStandardSchemaV1(...)` plus `Schema.toStandardJSONSchemaV1(...)`
|
||||
|
||||
Plugin tools are the main remaining intentional Zod boundary because the public
|
||||
plugin API exposes `tool.schema = z` and `args: z.ZodRawShape`.
|
||||
|
||||
### Local `DeepMutable<T>` in `config/config.ts`
|
||||
|
||||
`Schema.Struct` produces `readonly` types. Some consumer code (notably the
|
||||
`Config` service) mutates `Info` objects directly, so a readonly-stripping
|
||||
utility is needed when casting the derived zod schema's output type.
|
||||
|
||||
`Types.DeepMutable` from effect-smol would be a drop-in, but it widens
|
||||
`unknown` to `{}` in the fallback branch — a bug that affects any schema
|
||||
using `Schema.Record(String, Schema.Unknown)`.
|
||||
|
||||
Tracked upstream as `effect:core/x228my`: "Types.DeepMutable widens unknown
|
||||
to `{}`." Once that lands, the local `DeepMutable` copy can be deleted and
|
||||
`Types.DeepMutable` used directly.
|
||||
|
||||
## Ordering
|
||||
|
||||
Migrate in this order:
|
||||
|
||||
1. Shared leaf models and `schema.ts` files
|
||||
2. Exported `Info`, `Input`, `Output`, and DTO types
|
||||
3. Tagged domain errors
|
||||
4. Service-local internal models
|
||||
5. Route and tool boundary validators that can switch to native Effect Schema helpers
|
||||
|
||||
This keeps shared types canonical first and makes boundary updates mostly
|
||||
mechanical.
|
||||
|
||||
## Progress tracker
|
||||
|
||||
### `src/config/` ✅ complete
|
||||
|
||||
All of `packages/opencode/src/config/` has been migrated. The `export const
|
||||
<Info|Spec>` values are all Effect Schema at source.
|
||||
|
||||
A file is considered "done" when:
|
||||
|
||||
- its exported schema values (`Info`, `Input`, `Event`, `Definition`, etc.)
|
||||
are authored as Effect Schema
|
||||
- any remaining Zod is an explicit boundary compatibility choice, not a
|
||||
hand-written parallel source of truth
|
||||
|
||||
Files that meet this bar but still carry a compatibility boundary are checked
|
||||
off with an inline note describing the boundary and what unblocks its removal.
|
||||
|
||||
- [x] skills, formatter, console-state, mcp, lsp, permission (leaves), model-id, command, plugin, provider
|
||||
- [x] server, layout
|
||||
- [x] keybinds
|
||||
- [x] permission#Info
|
||||
- [x] agent
|
||||
- [x] config.ts root
|
||||
|
||||
### `src/*/schema.ts` leaf modules
|
||||
|
||||
These are the highest-priority next targets. Each is a small, self-contained
|
||||
schema module with a clear domain.
|
||||
|
||||
- [x] `src/account/schema.ts`
|
||||
- [x] `src/control-plane/schema.ts`
|
||||
- [x] `src/permission/schema.ts`
|
||||
- [x] `src/project/schema.ts`
|
||||
- [x] `src/provider/schema.ts`
|
||||
- [x] `src/pty/schema.ts`
|
||||
- [x] `src/question/schema.ts`
|
||||
- [x] `src/session/schema.ts`
|
||||
- [x] `src/storage/schema.ts`
|
||||
- [x] `src/sync/schema.ts`
|
||||
- [x] `src/tool/schema.ts`
|
||||
- [x] `src/util/schema.ts`
|
||||
|
||||
### Session domain
|
||||
|
||||
Major cluster. Message + event types flow through the SSE API and every SDK
|
||||
output, so byte-identical SDK surface is critical.
|
||||
|
||||
Suggested order for this cluster, starting from the leaves that `session.ts`
|
||||
and the SSE/event surface depend on:
|
||||
|
||||
1. `src/session/schema.ts` ✅ already migrated
|
||||
2. `src/provider/schema.ts` if `message-v2.ts` still relies on zod-first IDs
|
||||
3. `src/lsp/*` schema leaves needed by `LSP.Range`
|
||||
4. `src/snapshot/*` leaves used by `Snapshot.FileDiff`
|
||||
5. `src/session/message-v2.ts`
|
||||
6. `src/session/message.ts`
|
||||
7. `src/session/prompt.ts`
|
||||
8. `src/session/revert.ts`
|
||||
9. `src/session/summary.ts`
|
||||
10. `src/session/status.ts`
|
||||
11. `src/session/todo.ts`
|
||||
12. `src/session/session.ts`
|
||||
13. `src/session/compaction.ts`
|
||||
|
||||
Dependency sketch:
|
||||
|
||||
```text
|
||||
session.ts
|
||||
|- project/schema.ts
|
||||
|- control-plane/schema.ts
|
||||
|- permission/schema.ts
|
||||
|- snapshot/*
|
||||
|- message-v2.ts
|
||||
| |- provider/schema.ts
|
||||
| |- lsp/*
|
||||
| |- snapshot/*
|
||||
| |- sync/index.ts
|
||||
| `- bus/bus-event.ts
|
||||
|- sync/index.ts
|
||||
|- bus/bus-event.ts
|
||||
`- util/update-schema.ts
|
||||
```
|
||||
|
||||
Working rule for this cluster:
|
||||
|
||||
- migrate reusable leaf schemas and nested payload objects first
|
||||
- migrate aggregate DTOs like `Session.Info` after their nested pieces exist as
|
||||
named Schema values
|
||||
- leave zod-only event/update helpers in place temporarily when converting
|
||||
them would force unrelated churn across sync/bus boundaries
|
||||
|
||||
`message-v2.ts` first-pass outline:
|
||||
|
||||
1. Schema-backed imports already available
|
||||
- `SessionID`, `MessageID`, `PartID`
|
||||
- `ProviderID`, `ModelID`
|
||||
2. Local leaf objects to extract and migrate first
|
||||
- output format payloads
|
||||
- common part bases like `PartBase`
|
||||
- timestamp/range helper objects like `time.start/end`
|
||||
- file/source helper objects
|
||||
- token/cost/model helper objects
|
||||
3. Part variants built from those leaves
|
||||
- `SnapshotPart`, `PatchPart`, `TextPart`, `ReasoningPart`
|
||||
- `FilePart`, `AgentPart`, `CompactionPart`, `SubtaskPart`
|
||||
- retry/step/tool related parts
|
||||
4. Higher-level unions and DTOs
|
||||
- `FilePartSource`
|
||||
- part unions
|
||||
- message unions and assistant/user payloads
|
||||
5. Errors and event payloads last
|
||||
- `NamedError.create(...)` shapes can stay temporarily if converting them to
|
||||
`Schema.TaggedErrorClass` would force unrelated churn
|
||||
- `SyncEvent.define(...)` and `BusEvent.define(...)` payloads can use
|
||||
derived `.zod` at remaining zod-based HTTP/OpenAPI boundaries
|
||||
|
||||
Possible later tightening after the Schema-first migration is stable:
|
||||
|
||||
- promote repeated opaque strings and timestamp numbers into branded/newtype
|
||||
leaf schemas where that adds domain value without changing the wire format
|
||||
|
||||
- [x] `src/session/compaction.ts`
|
||||
- [x] `src/session/message-v2.ts`
|
||||
- [x] `src/session/message.ts`
|
||||
- [x] `src/session/prompt.ts`
|
||||
- [x] `src/session/revert.ts`
|
||||
- [x] `src/session/session.ts`
|
||||
- [x] `src/session/status.ts`
|
||||
- [x] `src/session/summary.ts`
|
||||
- [x] `src/session/todo.ts`
|
||||
|
||||
### Provider domain
|
||||
|
||||
- [x] `src/provider/auth.ts`
|
||||
- [x] `src/provider/models.ts`
|
||||
- [x] `src/provider/provider.ts`
|
||||
|
||||
### Tool schemas
|
||||
|
||||
Each tool declares its parameters via a zod schema. Tools are consumed by
|
||||
both the in-process runtime and the AI SDK's tool-calling layer, so the
|
||||
emitted JSON Schema must stay byte-identical.
|
||||
|
||||
- [x] `src/tool/apply_patch.ts`
|
||||
- [x] `src/tool/bash.ts`
|
||||
- [x] `src/tool/edit.ts`
|
||||
- [x] `src/tool/glob.ts`
|
||||
- [x] `src/tool/grep.ts`
|
||||
- [x] `src/tool/invalid.ts`
|
||||
- [x] `src/tool/lsp.ts`
|
||||
- [x] `src/tool/plan.ts`
|
||||
- [x] `src/tool/question.ts`
|
||||
- [x] `src/tool/read.ts`
|
||||
- [x] `src/tool/registry.ts`
|
||||
- [x] `src/tool/skill.ts`
|
||||
- [x] `src/tool/task.ts`
|
||||
- [x] `src/tool/todo.ts`
|
||||
- [x] `src/tool/tool.ts`
|
||||
- [x] `src/tool/webfetch.ts`
|
||||
- [x] `src/tool/websearch.ts`
|
||||
- [x] `src/tool/write.ts`
|
||||
|
||||
### HTTP route boundaries
|
||||
|
||||
The server route tree now lives under `src/server/routes/instance/httpapi` and
|
||||
uses Effect HttpApi contracts for request and response schemas. Remaining schema
|
||||
work is no longer a Hono route migration; it is compatibility cleanup around
|
||||
derived `.zod` statics, OpenAPI translation shims, and route groups that still
|
||||
need explicit SDK-visible error contracts.
|
||||
|
||||
Good follow-up targets:
|
||||
|
||||
- shrink `public.ts` legacy OpenAPI translation shims one SDK-compatible slice at a time
|
||||
- replace production `.zod.safeParse(...)` call sites with Effect Schema decoders
|
||||
- remove derived `.zod` statics after their production consumers are gone
|
||||
- declare route-group errors directly instead of relying on compatibility middleware
|
||||
|
||||
### Everything else
|
||||
|
||||
Small / shared / control-plane / CLI. Mostly independent; can be done
|
||||
piecewise.
|
||||
|
||||
- [ ] `src/acp/agent.ts`
|
||||
- [ ] `src/agent/agent.ts`
|
||||
- [x] `src/bus/bus-event.ts`
|
||||
- [ ] `src/bus/index.ts`
|
||||
- [ ] `src/cli/cmd/tui/config/tui-migrate.ts`
|
||||
- [ ] `src/cli/cmd/tui/config/tui-schema.ts`
|
||||
- [ ] `src/cli/cmd/tui/config/tui.ts`
|
||||
- [ ] `src/cli/cmd/tui/event.ts`
|
||||
- [ ] `src/cli/ui.ts`
|
||||
- [ ] `src/command/index.ts`
|
||||
- [x] `src/control-plane/adapters/worktree.ts`
|
||||
- [x] `src/control-plane/types.ts`
|
||||
- [x] `src/control-plane/workspace.ts`
|
||||
- [ ] `src/file/index.ts`
|
||||
- [ ] `src/file/ripgrep.ts`
|
||||
- [ ] `src/file/watcher.ts`
|
||||
- [ ] `src/format/index.ts`
|
||||
- [ ] `src/id/id.ts`
|
||||
- [ ] `src/ide/index.ts`
|
||||
- [ ] `src/installation/index.ts`
|
||||
- [ ] `src/lsp/client.ts`
|
||||
- [ ] `src/lsp/lsp.ts`
|
||||
- [ ] `src/mcp/auth.ts`
|
||||
- [ ] `src/patch/index.ts`
|
||||
- [ ] `src/plugin/github-copilot/models.ts`
|
||||
- [ ] `src/project/project.ts`
|
||||
- [ ] `src/project/vcs.ts`
|
||||
- [ ] `src/pty/index.ts`
|
||||
- [ ] `src/skill/index.ts`
|
||||
- [ ] `src/snapshot/index.ts`
|
||||
- [ ] `src/storage/db.ts`
|
||||
- [ ] `src/storage/storage.ts`
|
||||
- [x] `src/sync/index.ts` — public API (`SyncEvent.define`) is Schema-first; `payloads()` still derives zod for the remaining HTTP/OpenAPI boundary
|
||||
- [ ] `src/util/fn.ts`
|
||||
- [ ] `src/util/log.ts`
|
||||
- [ ] `src/util/update-schema.ts`
|
||||
- [ ] `src/worktree/index.ts`
|
||||
|
||||
## Notes
|
||||
|
||||
- Prefer one canonical schema definition. Avoid maintaining parallel Zod and
|
||||
Effect definitions for the same domain type.
|
||||
- Keep the migration incremental. Converting the domain model first is more
|
||||
valuable than converting every boundary in the same change.
|
||||
- Every migrated file should leave the generated SDK output (`packages/sdk/
|
||||
openapi.json` and `packages/sdk/js/src/v2/gen/types.gen.ts`) byte-identical
|
||||
unless the change is deliberately user-visible.
|
||||
- [ ] There is one schema source of truth for each migrated type.
|
||||
- [ ] Remaining Zod is an intentional boundary choice.
|
||||
- [ ] Public JSON/OpenAPI output is unchanged or intentionally updated.
|
||||
- [ ] Derived helpers are narrow and boundary-specific.
|
||||
- [ ] Tests assert behavior, not duplicated schema implementation details.
|
||||
|
||||
237
packages/opencode/specs/effect/todo.md
Normal file
237
packages/opencode/specs/effect/todo.md
Normal file
@@ -0,0 +1,237 @@
|
||||
# Effect TODO
|
||||
|
||||
Short roadmap for Effect cleanup in `packages/opencode`.
|
||||
|
||||
Current patterns and examples live in [`guide.md`](./guide.md). Test
|
||||
migration rules live in
|
||||
[`test/EFFECT_TEST_MIGRATION.md`](../../test/EFFECT_TEST_MIGRATION.md).
|
||||
Older deep-dive notes in this directory may still be useful, but treat
|
||||
this roadmap and the guide as the current entry points.
|
||||
|
||||
This is a planning map, not a verified inventory. Before starting a task,
|
||||
re-run a targeted `git grep` from current `dev` and update this file if
|
||||
the inventory changed.
|
||||
|
||||
## Priorities
|
||||
|
||||
```text
|
||||
P0 ERR + RENDER + HTTP
|
||||
Make expected failures typed, render them well, and stop relying on
|
||||
generic HTTP error guesswork.
|
||||
|
||||
P1 TEST
|
||||
Convert touched tests to the ideal Effect test patterns from the guide.
|
||||
|
||||
P2 RF
|
||||
Move mutable runtime flags into typed runtime/config services.
|
||||
|
||||
P3 GLOBAL
|
||||
Make global paths explicit and remove import-time side effects.
|
||||
|
||||
P4 INST + BRIDGE
|
||||
Remove ambient Instance coupling while keeping Promise/callback interop.
|
||||
|
||||
P5 PROC + FS
|
||||
Replace raw process/filesystem edges with typed Effect services.
|
||||
|
||||
P6 OA
|
||||
Shrink OpenAPI compatibility shims as source schemas improve.
|
||||
```
|
||||
|
||||
## Work Paths
|
||||
|
||||
- `ERR` Typed errors — replace legacy `NamedError.create(...)` and
|
||||
`Effect.die(...)` for expected service failures with
|
||||
`Schema.TaggedErrorClass` errors on the Effect error channel.
|
||||
Shrinks: [`NamedError`](../../../core/src/util/error.ts) usage.
|
||||
- `RENDER` User-visible error rendering — preserve structured typed-error
|
||||
details at CLI, HTTP, and tool boundaries.
|
||||
Shrinks: opaque `Error: Name` rendering.
|
||||
- `HTTP` HTTP route cleanup — make route errors explicit instead of
|
||||
relying on generic middleware to guess status/body from error names.
|
||||
Shrinks: [`middleware/error.ts`](../../src/server/routes/instance/httpapi/middleware/error.ts)
|
||||
and route-level compatibility shims.
|
||||
- `TEST` Effect test migration — use `testEffect`, `it.live`, and
|
||||
`it.instance` with explicit layers.
|
||||
Shrinks: Promise-style tests, sleeps, mutable global test flags.
|
||||
- `RF` RuntimeFlags / Flag deletion — move mutable
|
||||
[`Flag`](../../../core/src/flag/flag.ts) reads into typed runtime/config
|
||||
services.
|
||||
Shrinks: [`flag.ts`](../../../core/src/flag/flag.ts),
|
||||
[`test/fixture/flag.ts`](../../test/fixture/flag.ts).
|
||||
- `GLOBAL` Global paths / import side effects — make global path state
|
||||
explicit and testable instead of mutable module state.
|
||||
Shrinks: [`global.ts`](../../../core/src/global.ts) import-time side
|
||||
effects, mutable `Global.Path` overrides, and its `Flag` dependency.
|
||||
- `INST` Instance shim — remove ambient `Instance` usage and old ALS
|
||||
access patterns.
|
||||
Shrinks: [`src/project/instance.ts`](../../src/project/instance.ts).
|
||||
- `BRIDGE` Promise/callback interop — keep bridge helpers, but reduce
|
||||
legacy ALS coupling.
|
||||
Shrinks: [`src/effect/bridge.ts`](../../src/effect/bridge.ts)
|
||||
dependency on [`project/instance.ts`](../../src/project/instance.ts).
|
||||
- `PROC` AppProcess migration — prefer `AppProcess.Service` over raw
|
||||
process wrappers.
|
||||
Shrinks: direct spawn callsites and legacy process helpers.
|
||||
- `FS` AppFileSystem migration — prefer `AppFileSystem.Service` over raw
|
||||
filesystem APIs.
|
||||
Shrinks: direct `fs` / `Bun.file` service callsites where inappropriate.
|
||||
- `RT` Runtime/facade cleanup — remove service-local `makeRuntime`
|
||||
facades when not intentional.
|
||||
Shrinks: async facade exports around services and
|
||||
[`run-service.ts`](../../src/effect/run-service.ts) usage.
|
||||
- `OA` OpenAPI compatibility — tighten source schemas instead of
|
||||
post-processing generated OpenAPI.
|
||||
Shrinks: schema workaround blocks in
|
||||
[`public.ts`](../../src/server/routes/instance/httpapi/public.ts).
|
||||
|
||||
## P0: Errors, Rendering, And HTTP
|
||||
|
||||
This should be the next big cleanup theme. The codebase is moving toward
|
||||
typed Effect failures, but the user-facing boundaries still leak old
|
||||
shapes and sometimes collapse rich errors into opaque strings.
|
||||
|
||||
### Problems
|
||||
|
||||
- Some expected service failures still use `NamedError.create(...)`.
|
||||
- Some expected service failures still become `Effect.die(...)`, which
|
||||
makes them defects instead of typed, recoverable failures.
|
||||
- CLI and HTTP boundaries can render structured errors as generic
|
||||
`Error: SomeName` output.
|
||||
- HTTP error middleware still guesses status codes from error names like
|
||||
`Worktree*` or `ProviderAuthValidationFailed`.
|
||||
- Route handlers and route groups do not consistently declare the public
|
||||
error body they intend to expose.
|
||||
- Repeated route error translations do not yet have a clear home: some
|
||||
should stay inline, some deserve tiny shared mapper helpers.
|
||||
- Unknown 500s should log full detail server-side while returning a safe
|
||||
public body.
|
||||
|
||||
### Target Shape
|
||||
|
||||
- Services define expected failures with `Schema.TaggedErrorClass`.
|
||||
- Services export an `Error` union and include it in method return types.
|
||||
- Expected failures stay on the Effect error channel.
|
||||
- `Effect.die(...)` is reserved for defects: bugs, impossible states,
|
||||
violated invariants, or final unknown-boundary fallbacks.
|
||||
- Inside `Effect.gen` / `Effect.fn`, use `yield* new MyError(...)` for
|
||||
direct expected failures.
|
||||
- Domain services do not import HTTP status codes, `HttpApiError`, or
|
||||
route-specific error schemas.
|
||||
- HTTP route groups make their public error contracts obvious.
|
||||
- Handlers map service errors to declared HTTP errors at the boundary.
|
||||
- Shared mapper helpers are only for repeated translations, not a giant
|
||||
central registry of every domain error.
|
||||
- Generic HTTP middleware should shrink; it should not accumulate more
|
||||
name-based domain knowledge.
|
||||
|
||||
### First PR Candidates
|
||||
|
||||
- [ ] `RENDER-1` Fix CLI top-level rendering for typed config errors.
|
||||
- [ ] `ERR-1` Convert [`storage/storage.ts`](../../src/storage/storage.ts)
|
||||
not-found errors.
|
||||
- [ ] `ERR-2` Convert [`worktree/index.ts`](../../src/worktree/index.ts)
|
||||
errors and remove matching HTTP name checks where possible.
|
||||
- [ ] `ERR-3` Convert [`provider/auth.ts`](../../src/provider/auth.ts)
|
||||
validation errors.
|
||||
- [ ] `HTTP-1` Remove the unknown-500 stack leak from
|
||||
[`middleware/error.ts`](../../src/server/routes/instance/httpapi/middleware/error.ts).
|
||||
- [ ] `HTTP-2` Audit one route group for explicit error contracts and
|
||||
decide which mappings stay inline vs. shared helper.
|
||||
|
||||
## P1: Tests
|
||||
|
||||
When touching tests, migrate them toward the ideal patterns in
|
||||
[`test/EFFECT_TEST_MIGRATION.md`](../../test/EFFECT_TEST_MIGRATION.md):
|
||||
|
||||
- Use `testEffect(...)` with explicit layers.
|
||||
- Prefer `it.instance(...)` for service tests that need an instance.
|
||||
- Prefer `it.live(...)` for real timers, filesystem mtimes, child
|
||||
processes, git, locks, or other live integration behavior.
|
||||
- Avoid sleeps; wait on real events or deterministic state transitions.
|
||||
- Do not mutate `process.env` or mutable globals after layers are built.
|
||||
- Use explicit layer variants, such as `RuntimeFlags.layer(...)`, for
|
||||
behavior changes.
|
||||
|
||||
## P2: RuntimeFlags / Flag Deletion
|
||||
|
||||
Recently completed:
|
||||
|
||||
- [x] Plugin/pure-mode flags moved to RuntimeFlags.
|
||||
- [x] Tool visibility flags moved to RuntimeFlags.
|
||||
- [x] Built-in websearch provider selection uses the same runtime flags as
|
||||
tool visibility.
|
||||
- [x] Removed global default-plugin disabling from test preload.
|
||||
|
||||
Recommended next PRs:
|
||||
|
||||
```text
|
||||
RF-1 scout consumers ─┐
|
||||
├─ can run in parallel
|
||||
RF-2 plan-mode prompt ┘
|
||||
└─ RF-3 event-system cluster, stacked only if RF-2 still touches prompt.ts
|
||||
|
||||
RF-4 workspaces cluster: later, after mutable Flag tests are cleaned up
|
||||
```
|
||||
|
||||
- [ ] `RF-1` Move scout reads in [`agent.ts`](../../src/agent/agent.ts)
|
||||
and [`reference.ts`](../../src/reference/reference.ts).
|
||||
- [ ] `RF-2` Move plan-mode prompt read in
|
||||
[`session/prompt.ts`](../../src/session/prompt.ts).
|
||||
- [ ] `RF-3` Move event-system reads in session prompt/processor/
|
||||
compaction and TUI debug plugin.
|
||||
- [ ] `RF-4` Move workspaces reads in session/sync/control-plane after
|
||||
tests stop relying on mutable `Flag` timing.
|
||||
- [ ] Delete [`test/fixture/flag.ts`](../../test/fixture/flag.ts) once
|
||||
tests no longer mutate `Flag`.
|
||||
- [ ] Delete [`flag.ts`](../../../core/src/flag/flag.ts) once no packages
|
||||
import it.
|
||||
|
||||
## P3: Global Paths
|
||||
|
||||
[`global.ts`](../../../core/src/global.ts) is real connective tissue, not
|
||||
just cosmetic ugliness. It currently mixes path calculation, import-time
|
||||
directory creation, `Flock` setup, mutable exported `Path` state, and a
|
||||
`Flag` dependency.
|
||||
|
||||
Problems to reduce:
|
||||
|
||||
- Importing the module creates directories.
|
||||
- Tests override `Global.Path` by mutating exported module state.
|
||||
- Most callers use `Global.Path` directly instead of the Effect service.
|
||||
- `Global.make()` still reads mutable `Flag.OPENCODE_CONFIG_DIR`.
|
||||
|
||||
Next PR candidates:
|
||||
|
||||
- [ ] Replace mutable `Global.Path` test overrides with explicit test
|
||||
layers or scoped helpers.
|
||||
- [ ] Move directory creation and `Flock` setup behind an explicit init
|
||||
boundary where possible.
|
||||
- [ ] Remove the `Flag` dependency from global path resolution.
|
||||
|
||||
## P4: Instance And Bridge
|
||||
|
||||
[`project/instance.ts`](../../src/project/instance.ts) is the deletion
|
||||
target. [`effect/bridge.ts`](../../src/effect/bridge.ts) is not a near-term
|
||||
deletion target; Promise/callback interop will continue to exist.
|
||||
|
||||
Goal:
|
||||
|
||||
- Keep a sanctioned bridge for Promise/callback boundaries.
|
||||
- Reduce bridge dependence on legacy `Instance.restore` / `Instance.current`.
|
||||
- Move callers toward `InstanceRef`, `WorkspaceRef`, `InstanceState`, or
|
||||
explicit context where practical.
|
||||
- Delete `project/instance.ts` only after ambient Instance coupling is gone.
|
||||
|
||||
## Lower Priority Tracks
|
||||
|
||||
- `PROC` / `FS` — continue AppProcess and AppFileSystem migrations as
|
||||
focused PRs when touching relevant files.
|
||||
- `RT` — remove service-local runtime facades only when they are not an
|
||||
intentional boundary.
|
||||
- `OA` — shrink [`public.ts`](../../src/server/routes/instance/httpapi/public.ts)
|
||||
by tightening source schemas one workaround at a time.
|
||||
- `fetch` → `HttpClient` — migrate raw fetch callsites when the caller is
|
||||
already effectful or being effectified.
|
||||
- `Tools` — remaining tool cleanup is narrow: `webfetch` HTML extraction
|
||||
and `shell` raw stream/promise edges.
|
||||
@@ -1,16 +1,21 @@
|
||||
# Effect Test Migration Plan
|
||||
# Effect Test Migration
|
||||
|
||||
This document describes how to move opencode tests out of Promise-land and into the shared `testEffect` pattern.
|
||||
Move tests that exercise Effect services out of Promise-land and into the
|
||||
shared `testEffect` pattern.
|
||||
|
||||
This file is guidance, not a live inventory. Before claiming a migration,
|
||||
search current `dev` for the exact anti-pattern and update any PR notes
|
||||
with what you actually changed.
|
||||
|
||||
## Target Pattern
|
||||
|
||||
Every test file that exercises Effect services should have one local runner near the top:
|
||||
Every Effect service test should have one local runner near the top:
|
||||
|
||||
```ts
|
||||
const it = testEffect(layer)
|
||||
```
|
||||
|
||||
Then each test should use one of the runner methods:
|
||||
Use the runner method that matches the behavior:
|
||||
|
||||
```ts
|
||||
it.effect("pure service behavior", () =>
|
||||
@@ -23,7 +28,7 @@ it.effect("pure service behavior", () =>
|
||||
it.instance("instance-local behavior", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
// test.directory is a scoped temp opencode instance
|
||||
expect(test.directory).toContain("opencode-test-")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -35,40 +40,22 @@ it.live("live filesystem or process behavior", () =>
|
||||
)
|
||||
```
|
||||
|
||||
Use `it.effect` for pure Effect code that should run with `TestClock` and `TestConsole`.
|
||||
Use `it.instance` when the test needs one scoped opencode instance.
|
||||
Use `it.live` when the test depends on real time, filesystem mtimes, git, child processes, servers, file watchers, or OS behavior.
|
||||
## Choosing The Runner
|
||||
|
||||
## Anti-Patterns To Remove
|
||||
- `it.effect(...)` — pure Effect behavior with `TestClock` and
|
||||
`TestConsole`.
|
||||
- `it.instance(...)` — service behavior that needs one scoped opencode
|
||||
instance.
|
||||
- `it.live(...)` — real time, filesystem mtimes, child processes, git,
|
||||
locks, servers, watchers, or OS behavior.
|
||||
|
||||
Avoid these in tests that already target Effect services:
|
||||
|
||||
- `test(..., async () => Effect.runPromise(...))`
|
||||
- local `run(...)`, `load(...)`, `svc(...)`, or `runtime.runPromise(...)` wrappers that only provide a layer
|
||||
- `tmpdir()` plus `WithInstance.provide(...)` in Promise test bodies
|
||||
- custom `ManagedRuntime.make(...)` in test files
|
||||
- Promise `try/catch` around Effect failures
|
||||
- `Promise.withResolvers`, `Bun.sleep`, or `setTimeout` for synchronization when `Deferred`, `Fiber`, or `Effect.sleep` can express the same behavior
|
||||
|
||||
Promise helpers are acceptable at the boundary for non-Effect APIs, but they should be yielded from an Effect body with `Effect.promise(...)` rather than becoming the test harness.
|
||||
Most integration-style tests use `it.live(...)` or `it.instance(...)`.
|
||||
|
||||
## Layer Rules
|
||||
|
||||
Compose tests from open service layers, not closed `defaultLayer` graphs when a dependency needs replacing.
|
||||
|
||||
Good:
|
||||
|
||||
```ts
|
||||
const layer = Config.layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(AuthTest.empty),
|
||||
Layer.provide(AccountTest.empty),
|
||||
Layer.provide(NpmTest.noop),
|
||||
)
|
||||
```
|
||||
|
||||
Avoid using a fully closed layer and hoping to override an inner dependency later. Once `Agent.defaultLayer` has already provided `Config.defaultLayer`, tests cannot cleanly swap the `Npm.Service` used by that config layer.
|
||||
Compose tests from open service layers when a dependency needs replacing.
|
||||
Do not use a closed `defaultLayer` and then try to override an inner
|
||||
dependency after it has already been provided.
|
||||
|
||||
Prefer small reusable fake boundary layers in `test/fake/*`:
|
||||
|
||||
@@ -80,146 +67,103 @@ SkillTest.empty
|
||||
ProviderTest.fake().layer
|
||||
```
|
||||
|
||||
Do not add generic test-layer builders until repeated local compositions prove the need. Shared fake boundary services are the first reusable unit. Pre-composed subtrees such as `AgentTest.withPlugins` should come later, only after the same graph appears in multiple files.
|
||||
Use `Layer.mock` for partial service stubs. Missing methods should fail
|
||||
loudly if the test accidentally calls them.
|
||||
|
||||
Do not add generic test-layer builders until repeated local compositions
|
||||
prove the need.
|
||||
|
||||
## Fixture Rules
|
||||
|
||||
Use Effect-aware fixtures from `test/fixture/fixture.ts`:
|
||||
|
||||
- `TestInstance` inside `it.instance(...)` for the current temp instance path
|
||||
- `tmpdirScoped(...)` inside `Effect.gen` for additional temp directories
|
||||
- `provideInstance(dir)(effect)` when one test needs to switch instance context
|
||||
- `provideTmpdirInstance((dir) => effect, options)` when a live test needs custom instance setup or multiple instance scopes
|
||||
- `disposeAllInstances()` in `afterEach` only for integration tests that intentionally touch shared instance registries
|
||||
- `TestInstance` inside `it.instance(...)` for the current temp instance.
|
||||
- `tmpdirScoped(...)` inside `Effect.gen` for extra temp directories.
|
||||
- `provideInstance(dir)(effect)` when one test needs to switch instance
|
||||
context.
|
||||
- `provideTmpdirInstance((dir) => effect, options)` when a live test needs
|
||||
custom instance setup or multiple instance scopes.
|
||||
- `disposeAllInstances()` in `afterEach` only for integration tests that
|
||||
intentionally touch shared instance registries.
|
||||
|
||||
Use finalizers only as a temporary bridge for existing global mutations:
|
||||
Avoid mutable global setup. If a global mutation is unavoidable during a
|
||||
migration, scope it with acquire/release and treat it as temporary.
|
||||
|
||||
```ts
|
||||
yield *
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = process.env.MY_FLAG
|
||||
process.env.MY_FLAG = "1"
|
||||
return previous
|
||||
}),
|
||||
() => testBody,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
if (previous === undefined) delete process.env.MY_FLAG
|
||||
else process.env.MY_FLAG = previous
|
||||
}),
|
||||
)
|
||||
```
|
||||
Long term, tests should not toggle `process.env`, `Global.Path`, or
|
||||
mutable flags when behavior can be modeled with services. Prefer layers
|
||||
such as `RuntimeFlags.layer(...)` or focused fake services.
|
||||
|
||||
TODO: eliminate this pattern over time. Tests should not toggle process-global flags or env vars when the behavior can be modeled with services. Prefer moving flag/env reads behind injectable services such as `Config.Service`, `Env.Service`, or focused test layers, then provide the desired test value through the layer graph instead of mutating `process.env` or `Global.Path`.
|
||||
## Anti-Patterns To Remove
|
||||
|
||||
- `test(..., async () => Effect.runPromise(...))`
|
||||
- local `run(...)`, `load(...)`, `svc(...)`, or `runtime.runPromise(...)`
|
||||
wrappers that only provide a layer
|
||||
- `tmpdir()` plus legacy instance provision in Promise test bodies
|
||||
- custom `ManagedRuntime.make(...)` in test files
|
||||
- Promise `try/catch` around Effect failures
|
||||
- `Promise.withResolvers`, `Bun.sleep`, or `setTimeout` for synchronization
|
||||
when events, `Deferred`, fibers, or deterministic state checks fit
|
||||
- mutable env/global/flag changes after layers are built
|
||||
|
||||
Promise helpers are acceptable at non-Effect boundaries, but yield them from
|
||||
inside an Effect body with `Effect.promise(...)` rather than making them the
|
||||
test harness.
|
||||
|
||||
## Conversion Recipe
|
||||
|
||||
1. Identify the real service under test and its open `*.layer`.
|
||||
2. Build one top-level `layer` with real dependencies where they are relevant and `test/fake/*` layers at slow or external boundaries.
|
||||
3. Replace local Promise wrappers with Effect helpers:
|
||||
|
||||
```ts
|
||||
const run = Effect.fn("MyTest.run")(function* (input: Input) {
|
||||
const service = yield* MyService.Service
|
||||
return yield* service.run(input)
|
||||
})
|
||||
```
|
||||
|
||||
4. Convert `test(..., async () => { ... })` to `it.effect`, `it.instance`, or `it.live`.
|
||||
1. Identify the real service under test and whether its open `layer` or
|
||||
closed `defaultLayer` is appropriate.
|
||||
2. Build one top-level `layer` with real dependencies where relevant and
|
||||
fake layers at slow or external boundaries.
|
||||
3. Replace local Promise wrappers with Effect helpers.
|
||||
4. Convert `test(..., async () => { ... })` to `it.effect`, `it.instance`,
|
||||
or `it.live`.
|
||||
5. Move `await` calls inside `Effect.gen` as `yield*` calls.
|
||||
6. Replace `await using tmp = await tmpdir(...)` with `yield* tmpdirScoped(...)` when the temp directory is inside an Effect test.
|
||||
7. Replace `WithInstance.provide({ directory, fn })` with `it.instance(...)`, `provideInstance(directory)(effect)`, or `provideTmpdirInstance(...)`.
|
||||
8. Replace Promise failure assertions with Effect assertions:
|
||||
|
||||
```ts
|
||||
const exit = yield * run(input).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
```
|
||||
|
||||
This is correct but still verbose. Track repeated assertion shapes during migration so we can add small test assertion helpers later instead of copying low-level `Exit` plumbing everywhere.
|
||||
|
||||
9. Keep concurrency concurrent by using `Effect.forkScoped`, `Fiber.join`, `Deferred`, or `Effect.all(..., { concurrency: "unbounded" })` instead of serializing formerly parallel Promise work.
|
||||
10. Run the focused test file and `bun typecheck` from `packages/opencode`.
|
||||
6. Replace `await using tmp = await tmpdir(...)` with
|
||||
`yield* tmpdirScoped(...)` when the temp directory lives inside the
|
||||
Effect test.
|
||||
7. Replace Promise failure assertions with `Effect.exit`, `Effect.flip`, or
|
||||
focused assertion helpers.
|
||||
8. Preserve concurrency with fibers, `Deferred`, and
|
||||
`Effect.all(..., { concurrency: "unbounded" })`; do not accidentally
|
||||
serialize formerly parallel behavior.
|
||||
9. Run the focused test file and `bun typecheck` from `packages/opencode`.
|
||||
|
||||
## Good Examples
|
||||
|
||||
Use these files as models:
|
||||
Use current examples as patterns, but re-check them before copying because
|
||||
test migrations are active:
|
||||
|
||||
- `test/tool/write.test.ts`: strong `it.instance` tests, top-level `testEffect(...)`, and Effect-native test helpers.
|
||||
- `test/effect/instance-state.test.ts`: good `it.live` use for scoped directories, instance switching, reload/disposal, and concurrency.
|
||||
- `test/bus/bus-effect.test.ts`: good `Deferred`, streams, and scoped fibers.
|
||||
- `test/tool/truncation.test.ts`: good configured runners and concise live service tests.
|
||||
- `test/tool/repo_clone.test.ts`: good live git integration while staying inside Effect fixtures.
|
||||
- `test/server/httpapi-instance.test.ts`: good scoped integration layer setup and live HTTP assertions.
|
||||
- `test/account/service.test.ts`: good service-level live tests, `Effect.flip`, typed errors, and fake HTTP clients.
|
||||
- `test/agent/plugin-agent-regression.test.ts`: good example of open real service layers plus reusable fake boundary layers.
|
||||
- `test/effect/instance-state.test.ts` — scoped directories, instance
|
||||
switching, disposal, and concurrency.
|
||||
- `test/bus/bus-effect.test.ts` — `Deferred`, streams, scoped fibers.
|
||||
- `test/agent/plugin-agent-regression.test.ts` — real service layers plus
|
||||
fake boundary layers.
|
||||
- `test/account/service.test.ts` — service-level live tests, typed errors,
|
||||
fake HTTP clients.
|
||||
|
||||
## Current Promise-Land Hotspots
|
||||
## Migration Queue Policy
|
||||
|
||||
Start with files that already exercise Effect services but still manually run Promises:
|
||||
Do not maintain a long file checklist here. It goes stale quickly.
|
||||
|
||||
- `test/config/config.test.ts`: many `Effect.runPromise`, `tmpdir()`, and `WithInstance.provide(...)` patterns despite already having `const it = testEffect(layer)`.
|
||||
- `test/tool/shell.test.ts`: custom `ManagedRuntime`, Promise test helpers, and instance setup around shell execution.
|
||||
- `test/tool/edit.test.ts`: manual runtime helpers and Promise concurrency patterns that should become fibers/deferreds.
|
||||
- `test/session/messages-pagination.test.ts`: local Promise service facade over `Session.defaultLayer`.
|
||||
- `test/snapshot/snapshot.test.ts`: Promise helper with `provideInstance` around snapshot operations.
|
||||
- `test/file/index.test.ts`: Promise wrappers for `File.Service` plus repeated temp instance setup.
|
||||
- `test/provider/provider.test.ts`: `AppRuntime.runPromise` helpers and mutable env/config setup.
|
||||
- `test/project/vcs.test.ts`: Promise event waiting and `AppRuntime.runPromise` around VCS service calls.
|
||||
When looking for the next target, search for current anti-patterns:
|
||||
|
||||
## Migration Order
|
||||
```bash
|
||||
git grep -n "Effect.runPromise\|ManagedRuntime\|Promise.withResolvers\|Bun.sleep\|WithInstance" -- packages/opencode/test
|
||||
```
|
||||
|
||||
1. Convert one small file with straightforward service calls and no race behavior.
|
||||
2. Convert `config.test.ts` incrementally by cluster, not in one PR.
|
||||
3. Extract additional `test/fake/*` boundary layers only when a second test needs the same fake.
|
||||
4. Convert files with concurrency or watchers after the simple files, preserving timing semantics with `Deferred` and fibers.
|
||||
5. Leave pure non-Effect utility tests alone unless converting the underlying code to Effect.
|
||||
Then choose one file or one small cluster, keep the PR focused, and mention
|
||||
the focused verification in the PR body.
|
||||
|
||||
## Claimable Checklist
|
||||
## Rough Edges To Watch
|
||||
|
||||
Use this as a migration queue. Each checkbox should be safe for one agent or one PR unless the notes say otherwise. Agents should claim one item, convert only that file or cluster, run the focused test file, run `bun typecheck`, and update this checklist in the PR description or follow-up note.
|
||||
|
||||
- [ ] `test/file/index.test.ts`: straightforward service wrapper cleanup. Replace local Promise helpers with Effect helpers and use `it.instance` / `it.live` around existing temp instance cases.
|
||||
- [ ] `test/session/messages-pagination.test.ts`: convert the local `run(...)` / `svc(...)` facade to `testEffect(Session.defaultLayer...)` and direct service yields. Good early target.
|
||||
- [ ] `test/snapshot/snapshot.test.ts`: convert snapshot operations to `it.live` with `tmpdirScoped` / `provideInstance`. Keep git/filesystem behavior live.
|
||||
- [ ] `test/project/vcs.test.ts`: convert `AppRuntime.runPromise` service calls first. Leave event/watcher timing intact until the first Effect version is stable.
|
||||
- [ ] `test/provider/provider.test.ts` cluster 1: convert provider service tests that only read config/env and do not mutate global state heavily.
|
||||
- [ ] `test/provider/provider.test.ts` cluster 2: convert tests with env/config mutation after introducing or reusing service-backed test seams.
|
||||
- [ ] `test/tool/shell.test.ts`: replace custom `ManagedRuntime` with `testEffect`, keep as `it.live`, and preserve process behavior.
|
||||
- [ ] `test/tool/edit.test.ts` cluster 1: convert straightforward edit/read/write cases and remove manual runtime helpers.
|
||||
- [ ] `test/tool/edit.test.ts` cluster 2: convert concurrency/race tests using `Deferred`, fibers, and `Effect.all` without serializing behavior.
|
||||
- [ ] `test/config/config.test.ts` setup pass: replace inline fake layers with shared `test/fake/*` layers where possible and turn Promise helpers into Effect helpers.
|
||||
- [ ] `test/config/config.test.ts` cluster 1: convert simple config load/merge tests that only need one instance.
|
||||
- [ ] `test/config/config.test.ts` cluster 2: convert managed/global config tests that mutate `Global.Path` or managed config directories. Prefer service seams; use finalizers only as a bridge.
|
||||
- [ ] `test/config/config.test.ts` cluster 3: convert plugin/dependency tests after ensuring `NpmTest.noop` or explicit fake NPM layers are used.
|
||||
- [ ] `test/config/config.test.ts` cluster 4: convert remote/account/provider config tests after isolating auth/account/env dependencies through layers.
|
||||
- [ ] Audit remaining `Effect.runPromise` in `packages/opencode/test/**/*.ts` and create follow-up checklist entries for any missed files.
|
||||
- [ ] Audit remaining `WithInstance.provide` in `packages/opencode/test/**/*.ts` and convert cases that can use `it.instance` or `provideInstance` inside Effect.
|
||||
- [ ] Audit repeated `Exit` / `Cause` assertion shapes and propose `test/lib/effect-assert.ts` helpers if at least three files repeat the same pattern.
|
||||
|
||||
Parallelization notes:
|
||||
|
||||
- The first four items are mostly independent and good for separate worktrees.
|
||||
- `provider.test.ts`, `tool/edit.test.ts`, and `config.test.ts` should be split by cluster so agents do not edit the same file concurrently.
|
||||
- Any new fake boundary layer under `test/fake/*` should be small and independently useful. Do not add a fake just for one assertion unless it removes a real external dependency.
|
||||
- Do not combine assertion-helper design with file migrations. First collect repeated shapes, then add helpers in a separate pass.
|
||||
|
||||
Orchestration rules:
|
||||
|
||||
- Prefer supervised foreground agents for implementation. Background agents are acceptable for research-only surveys, but code migrations need a returned diff, focused test output, and local commit before moving on.
|
||||
- Create one worktree per claim and verify the branch/worktree path before edits. A status check should include `git status --short --branch` from the claimed worktree.
|
||||
- After an agent reports completion, the coordinator must independently inspect `git status`, run the focused test, run `bun typecheck`, and review the diff before pushing.
|
||||
- If an agent edits the wrong worktree, move the patch deliberately with `git diff` / `git apply`, then clean the accidental worktree before opening a PR.
|
||||
- Keep dependency setup boring. Prefer reusing existing installed dependencies via worktrees or symlinks over running a fresh `bun install` in a temporary path unless the native build path is known to work.
|
||||
- Do not delete worktrees with unpushed commits or uncommitted changes. Once a migration PR branch is pushed and clean, the local worktree can be removed while leaving the branch on the fork.
|
||||
|
||||
## Effectified Test Rough Edges
|
||||
|
||||
Track patterns that are technically Effect-native but still too noisy. These should become a second cleanup pass after the Promise-land migration is underway.
|
||||
|
||||
- Failure assertions against `Exit` / `Cause` are often verbose. Consider helpers such as `expectEffectFailure(effect)`, `expectTaggedError(effect, Tag)`, or custom Bun matchers if the same shapes repeat.
|
||||
- Some tests still need `Effect.promise(...)` around Node/Bun filesystem helpers. Prefer Effect platform services when the surrounding code already uses them, but do not block migrations on perfect filesystem abstraction.
|
||||
- Scoped global mutation with `process.env`, `Global.Path`, or flags should disappear behind injectable services over time.
|
||||
- Layer composition can be noisy when a test needs a real service subtree plus fake boundaries. Keep extracting small `test/fake/*` boundary layers before inventing larger builders.
|
||||
- Concurrency tests can become harder to read after replacing Promise resolvers with `Deferred` and fibers. Look for repeated patterns that deserve named helpers.
|
||||
- Failure assertions against `Exit` / `Cause` can get verbose. Add helpers
|
||||
only after the same shape repeats across multiple files.
|
||||
- Some tests still need `Effect.promise(...)` around Node/Bun APIs. Prefer
|
||||
Effect platform services when the surrounding code already uses them, but
|
||||
do not block useful migrations on perfect abstraction.
|
||||
- Layer composition can be noisy when a test needs real service subtrees plus
|
||||
fake boundaries. Extract small `test/fake/*` layers before inventing
|
||||
larger builders.
|
||||
- Concurrency tests can get harder to read after replacing Promise
|
||||
resolvers. Look for repeated patterns that deserve named helpers.
|
||||
|
||||
Reference in New Issue
Block a user