Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton
6b5dff1779 refactor(session): type not found errors 2026-05-05 00:28:54 -04:00
11 changed files with 494 additions and 89 deletions

View File

@@ -0,0 +1,329 @@
# Typed error migration
Plan for moving `packages/opencode` from temporary defect/`NamedError`
compatibility toward typed Effect service errors and explicit HTTP error
contracts.
## 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.
## 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`.
- Legacy Hono error handling 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.
```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>
}
```
HTTP modules own transport mapping.
```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 })),
)
})
```
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.
```ts
export class SessionNotFoundHttpError extends Schema.TaggedErrorClass<SessionNotFoundHttpError>()(
"SessionNotFoundHttpError",
{
sessionID: SessionID,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
```
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.
```ts
HttpApiEndpoint.get("get", SessionPaths.get, {
success: Session.Info,
error: [SessionNotFoundHttpError, SessionBusyHttpError],
})
```
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.
## Rules
- 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.
## HTTP Boundary Shape
Create an HttpApi-local error module, likely
`src/server/routes/instance/httpapi/errors.ts`.
That module should provide:
- Legacy-compatible public schemas for `{ name, data }` error bodies that must
remain SDK-compatible during the Hono migration.
- 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.
Avoid one giant `unknown -> status` mapper. Prefer small, explicit mappers close
to the handler or route group.
```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 })),
)
```
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 legacy Hono 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 Hono deletion work progresses.
### 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 bridge/parity tests aligned with legacy Hono behavior until Hono is
deleted or the SDK contract 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.

View File

@@ -9,6 +9,7 @@ import { Locale } from "@/util/locale"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { NotFoundError } from "@/storage/storage"
import { EOL } from "os"
import path from "path"
import { which } from "../../util/which"
@@ -59,9 +60,12 @@ export const SessionDeleteCommand = effectCmd({
handler: Effect.fn("Cli.session.delete")(function* (args) {
const svc = yield* Session.Service
const sessionID = SessionID.make(args.sessionID)
// Match legacy try/catch — Session.get surfaces NotFoundError as a defect.
yield* svc.get(sessionID).pipe(Effect.catchCause(() => fail(`Session not found: ${args.sessionID}`)))
yield* svc.remove(sessionID)
yield* svc.remove(sessionID).pipe(
Effect.catchIf(
(error): error is InstanceType<typeof NotFoundError> => NotFoundError.isInstance(error),
() => fail(`Session not found: ${args.sessionID}`),
),
)
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL)
}),
})

View File

@@ -28,7 +28,7 @@ import { errorData } from "@/util/error"
import { waitEvent } from "./util"
import { WorkspaceContext } from "./workspace-context"
import { EffectBridge } from "@/effect/bridge"
import { NonNegativeInt, withStatics } from "@/util/schema"
import { withStatics } from "@/util/schema"
import { zod as effectZod, zodObject } from "@/util/effect-zod"
export const Info = WorkspaceInfoSchema
@@ -739,9 +739,18 @@ export const layer = Layer.effect(
const remove = Effect.fn("Workspace.remove")(function* (id: WorkspaceID) {
const sessions = yield* db((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, id)).all(),
db
.select({ id: SessionTable.id, parentID: SessionTable.parent_id })
.from(SessionTable)
.where(eq(SessionTable.workspace_id, id))
.all(),
)
const sessionIDs = new Set(sessions.map((sessionInfo) => sessionInfo.id))
yield* Effect.forEach(
sessions.filter((sessionInfo) => !sessionInfo.parentID || !sessionIDs.has(sessionInfo.parentID)),
(sessionInfo) => session.remove(sessionInfo.id).pipe(Effect.orDie),
{ discard: true },
)
yield* Effect.forEach(sessions, (sessionInfo) => session.remove(sessionInfo.id), { discard: true })
const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return

View File

@@ -67,6 +67,16 @@ export const PermissionResponsePayload = Schema.Struct({
response: Permission.Reply,
})
export class SessionNotFoundError extends Schema.ErrorClass<SessionNotFoundError>("NotFoundError")(
{
name: Schema.Literal("NotFoundError"),
data: Schema.Struct({
message: Schema.String,
}),
},
{ httpApiStatus: 404 },
) {}
export const SessionPaths = {
list: root,
status: `${root}/status`,
@@ -123,7 +133,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.get("get", SessionPaths.get, {
params: { sessionID: SessionID },
success: described(Session.Info, "Get session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.get",
@@ -168,7 +178,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
query: MessagesQuery,
success: described(Schema.Array(MessageV2.WithParts), "List of messages"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.messages",
@@ -179,7 +189,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.get("message", SessionPaths.message, {
params: { sessionID: SessionID, messageID: MessageID },
success: described(MessageV2.WithParts, "Message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.message",
@@ -201,7 +211,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.delete("remove", SessionPaths.remove, {
params: { sessionID: SessionID },
success: described(Schema.Boolean, "Successfully deleted session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.delete",
@@ -213,7 +223,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
payload: UpdatePayload,
success: described(Session.Info, "Successfully updated session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.update",
@@ -225,6 +235,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
payload: ForkPayload,
success: described(Session.Info, "200"),
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.fork",
@@ -259,7 +270,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.post("share", SessionPaths.share, {
params: { sessionID: SessionID },
success: described(Session.Info, "Successfully shared session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.share",
@@ -270,7 +281,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.delete("unshare", SessionPaths.share, {
params: { sessionID: SessionID },
success: described(Session.Info, "Successfully unshared session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.unshare",
@@ -282,7 +293,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
payload: SummarizePayload,
success: described(Schema.Boolean, "Summarized session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.summarize",

View File

@@ -33,18 +33,19 @@ import {
PermissionResponsePayload,
PromptPayload,
RevertPayload,
SessionNotFoundError,
ShellPayload,
SummarizePayload,
UpdatePayload,
} from "../groups/session"
const mapNotFound = <A, E, R>(self: Effect.Effect<A, E, R>) =>
self.pipe(
Effect.catchIf(NotFoundError.isInstance, () => Effect.fail(new HttpApiError.NotFound({}))),
Effect.catchDefect((error) =>
NotFoundError.isInstance(error) ? Effect.fail(new HttpApiError.NotFound({})) : Effect.die(error),
),
)
type StorageNotFound = InstanceType<typeof NotFoundError>
const sessionNotFound = (error: StorageNotFound) =>
new SessionNotFoundError({ name: "NotFoundError", data: error.data })
const mapNotFound = <A, R>(self: Effect.Effect<A, StorageNotFound, R>): Effect.Effect<A, SessionNotFoundError, R> =>
self.pipe(Effect.mapError(sessionNotFound))
export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) =>
Effect.gen(function* () {
@@ -101,51 +102,50 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID }
query: typeof MessagesQuery.Type
}) {
return yield* mapNotFound(
Effect.gen(function* () {
if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({})
if (ctx.query.before) {
const before = ctx.query.before
yield* Effect.try({
try: () => MessageV2.cursor.decode(before),
catch: () => new HttpApiError.BadRequest({}),
})
}
if (ctx.query.limit === undefined || ctx.query.limit === 0) {
yield* session.get(ctx.params.sessionID)
return yield* session.messages({ sessionID: ctx.params.sessionID })
}
if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({})
if (ctx.query.before) {
const before = ctx.query.before
yield* Effect.try({
try: () => MessageV2.cursor.decode(before),
catch: () => new HttpApiError.BadRequest({}),
})
}
if (ctx.query.limit === undefined || ctx.query.limit === 0) {
yield* mapNotFound(session.get(ctx.params.sessionID))
return yield* session.messages({ sessionID: ctx.params.sessionID })
}
yield* session.get(ctx.params.sessionID)
const page = MessageV2.page({
sessionID: ctx.params.sessionID,
limit: ctx.query.limit,
before: ctx.query.before,
})
if (!page.cursor) return page.items
yield* mapNotFound(session.get(ctx.params.sessionID))
const page = MessageV2.page({
sessionID: ctx.params.sessionID,
limit: ctx.query.limit,
before: ctx.query.before,
})
if (!page.cursor) return page.items
const request = yield* HttpServerRequest.HttpServerRequest
// toURL() honors the Host + x-forwarded-proto headers, so the Link
// header echoes the real origin instead of a hard-coded localhost.
const url = Option.getOrElse(HttpServerRequest.toURL(request), () => new URL(request.url, "http://localhost"))
url.searchParams.set("limit", ctx.query.limit.toString())
url.searchParams.set("before", page.cursor)
return HttpServerResponse.jsonUnsafe(page.items, {
headers: {
"Access-Control-Expose-Headers": "Link, X-Next-Cursor",
Link: `<${url.toString()}>; rel="next"`,
"X-Next-Cursor": page.cursor,
},
})
}),
)
const request = yield* HttpServerRequest.HttpServerRequest
// toURL() honors the Host + x-forwarded-proto headers, so the Link
// header echoes the real origin instead of a hard-coded localhost.
const url = Option.getOrElse(HttpServerRequest.toURL(request), () => new URL(request.url, "http://localhost"))
url.searchParams.set("limit", ctx.query.limit.toString())
url.searchParams.set("before", page.cursor)
return HttpServerResponse.jsonUnsafe(page.items, {
headers: {
"Access-Control-Expose-Headers": "Link, X-Next-Cursor",
Link: `<${url.toString()}>; rel="next"`,
"X-Next-Cursor": page.cursor,
},
})
})
const message = Effect.fn("SessionHttpApi.message")(function* (ctx: {
params: { sessionID: SessionID; messageID: MessageID }
}) {
return yield* mapNotFound(
Effect.sync(() => MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID })),
Effect.try({
try: () => MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID }),
catch: (error) => error,
}).pipe(Effect.catch((error) => (NotFoundError.isInstance(error) ? Effect.fail(error) : Effect.die(error)))),
)
})
@@ -170,7 +170,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
})
const remove = Effect.fn("SessionHttpApi.remove")(function* (ctx: { params: { sessionID: SessionID } }) {
yield* session.remove(ctx.params.sessionID)
yield* mapNotFound(session.remove(ctx.params.sessionID))
return true
})
@@ -178,7 +178,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID }
payload: typeof UpdatePayload.Type
}) {
const current = yield* session.get(ctx.params.sessionID)
const current = yield* mapNotFound(session.get(ctx.params.sessionID))
if (ctx.payload.title !== undefined) {
yield* session.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title })
}
@@ -191,14 +191,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
if (ctx.payload.time?.archived !== undefined) {
yield* session.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived })
}
return yield* session.get(ctx.params.sessionID)
return yield* mapNotFound(session.get(ctx.params.sessionID))
})
const fork = Effect.fn("SessionHttpApi.fork")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof ForkPayload.Type
}) {
return yield* session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID })
return yield* mapNotFound(session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }))
})
const abort = Effect.fn("SessionHttpApi.abort")(function* (ctx: { params: { sessionID: SessionID } }) {
@@ -222,19 +222,19 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
const share = Effect.fn("SessionHttpApi.share")(function* (ctx: { params: { sessionID: SessionID } }) {
yield* shareSvc.share(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
return yield* session.get(ctx.params.sessionID)
return yield* mapNotFound(session.get(ctx.params.sessionID))
})
const unshare = Effect.fn("SessionHttpApi.unshare")(function* (ctx: { params: { sessionID: SessionID } }) {
yield* shareSvc.unshare(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
return yield* session.get(ctx.params.sessionID)
return yield* mapNotFound(session.get(ctx.params.sessionID))
})
const summarize = Effect.fn("SessionHttpApi.summarize")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof SummarizePayload.Type
}) {
yield* revertSvc.cleanup(yield* session.get(ctx.params.sessionID))
yield* revertSvc.cleanup(yield* mapNotFound(session.get(ctx.params.sessionID)))
const messages = yield* session.messages({ sessionID: ctx.params.sessionID })
const defaultAgent = yield* agentSvc.defaultAgent()
const currentAgent = messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent

View File

@@ -7,6 +7,7 @@ import { Session } from "@/session/session"
import { HttpApiProxy } from "./proxy"
import * as Fence from "@/server/shared/fence"
import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/shared/workspace-routing"
import { NotFoundError } from "@/storage/storage"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Context, Data, Effect, Layer } from "effect"
import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
@@ -178,7 +179,13 @@ function routeHttpApiWorkspace<E>(
const request = yield* HttpServerRequest.HttpServerRequest
const sessionID = getWorkspaceRouteSessionID(requestURL(request))
const session = sessionID
? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(Effect.catchDefect(() => Effect.void))
? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(
Effect.catchIf(
(error): error is InstanceType<typeof NotFoundError> => NotFoundError.isInstance(error),
() => Effect.succeed(undefined),
),
Effect.catchDefect(() => Effect.succeed(undefined)),
)
: undefined
const plan = yield* planRequest(request, session?.workspaceID)
return yield* routeWorkspace(client, effect, plan)

View File

@@ -744,7 +744,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const markReady = ready ? ready.open.pipe(Effect.asVoid) : Effect.void
const { msg, part, cwd } = yield* Effect.gen(function* () {
const ctx = yield* InstanceState.context
const session = yield* sessions.get(input.sessionID)
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
if (session.revert) {
yield* revert.cleanup(session)
}
@@ -1370,7 +1370,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.prompt")(
function* (input: PromptInput) {
const session = yield* sessions.get(input.sessionID)
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
yield* revert.cleanup(session)
const message = yield* createUserMessage(input)
yield* sessions.touch(input.sessionID)
@@ -1401,9 +1401,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
function* (sessionID: SessionID) {
const ctx = yield* InstanceState.context
const slog = elog.with({ sessionID })
let structured: unknown | undefined
let structured: unknown
let step = 0
const session = yield* sessions.get(sessionID)
const session = yield* sessions.get(sessionID).pipe(Effect.orDie)
while (true) {
yield* status.set(sessionID, { type: "busy" })

View File

@@ -44,7 +44,7 @@ export const layer = Layer.effect(
yield* state.assertNotBusy(input.sessionID)
const all = yield* sessions.messages({ sessionID: input.sessionID })
let lastUser: MessageV2.User | undefined
const session = yield* sessions.get(input.sessionID)
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
let rev: Session.Info["revert"]
const patches: Snapshot.Patch[] = []
@@ -75,8 +75,8 @@ export const layer = Layer.effect(
rev.snapshot = session.revert?.snapshot ?? (yield* snap.track())
if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot)
yield* snap.revert(patches)
if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot as string)
const range = all.filter((msg) => msg.info.id >= rev!.messageID)
if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot)
const range = all.filter((msg) => msg.info.id >= rev.messageID)
const diffs = yield* summary.computeDiff({ messages: range })
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
@@ -89,17 +89,17 @@ export const layer = Layer.effect(
files: diffs.length,
},
})
return yield* sessions.get(input.sessionID)
return yield* sessions.get(input.sessionID).pipe(Effect.orDie)
})
const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) {
log.info("unreverting", input)
yield* state.assertNotBusy(input.sessionID)
const session = yield* sessions.get(input.sessionID)
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
if (!session.revert) return session
if (session.revert.snapshot) yield* snap.restore(session.revert!.snapshot!)
if (session.revert.snapshot) yield* snap.restore(session.revert.snapshot)
yield* sessions.clearRevert(input.sessionID)
return yield* sessions.get(input.sessionID)
return yield* sessions.get(input.sessionID).pipe(Effect.orDie)
})
const cleanup = Effect.fn("SessionRevert.cleanup")(function* (session: Session.Info) {

View File

@@ -3,7 +3,6 @@ import path from "path"
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { Decimal } from "decimal.js"
import z from "zod"
import { type ProviderMetadata, type LanguageModelUsage } from "ai"
import { Flag } from "@opencode-ai/core/flag/flag"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
@@ -422,6 +421,8 @@ export class BusyError extends Error {
}
}
export type NotFound = InstanceType<typeof NotFoundError>
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
readonly create: (input?: {
@@ -432,9 +433,9 @@ export interface Interface {
permission?: Permission.Ruleset
workspaceID?: WorkspaceID
}) => Effect.Effect<Info>
readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info>
readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info, NotFound>
readonly touch: (sessionID: SessionID) => Effect.Effect<void>
readonly get: (id: SessionID) => Effect.Effect<Info>
readonly get: (id: SessionID) => Effect.Effect<Info, NotFound>
readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect<void>
readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect<void>
readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect<void>
@@ -448,7 +449,7 @@ export interface Interface {
readonly diff: (sessionID: SessionID) => Effect.Effect<Snapshot.FileDiff[]>
readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect<MessageV2.WithParts[]>
readonly children: (parentID: SessionID) => Effect.Effect<Info[]>
readonly remove: (sessionID: SessionID) => Effect.Effect<void>
readonly remove: (sessionID: SessionID) => Effect.Effect<void, NotFound>
readonly updateMessage: <T extends MessageV2.Info>(msg: T) => Effect.Effect<T>
readonly removeMessage: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect<MessageID>
readonly removePart: (input: { sessionID: SessionID; messageID: MessageID; partID: PartID }) => Effect.Effect<PartID>
@@ -534,13 +535,13 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
const get = Effect.fn("Session.get")(function* (id: SessionID) {
const row = yield* db((d) => d.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
if (!row) return yield* Effect.fail(new NotFoundError({ message: `Session not found: ${id}` }))
return fromRow(row)
})
const list = Effect.fn("Session.list")(function* (input?: ListInput) {
const ctx = yield* InstanceState.context
return Array.from(listByProject({ projectID: ctx.project.id, ...(input ?? {}) }))
return Array.from(listByProject({ projectID: ctx.project.id, ...input }))
})
const children = Effect.fn("Session.children")(function* (parentID: SessionID) {
@@ -555,8 +556,8 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
})
const remove: Interface["remove"] = Effect.fnUntraced(function* (sessionID: SessionID) {
const session = yield* get(sessionID)
try {
const session = yield* get(sessionID)
const kids = yield* children(sessionID)
for (const child of kids) {
yield* remove(child.id)

View File

@@ -1060,7 +1060,7 @@ describe("workspace-old sync state", () => {
yield* eventuallyEffect(
Effect.gen(function* () {
expect((yield* sessionSvc.get(session.id)).title).toBe("from history")
expect((yield* sessionSvc.get(session.id).pipe(Effect.orDie)).title).toBe("from history")
}),
)
expect(historyBodies).toEqual([{ [session.id]: historyNextSeq - 1 }])
@@ -1208,7 +1208,7 @@ describe("workspace-old sync state", () => {
yield* eventuallyEffect(
Effect.gen(function* () {
expect((yield* sessionSvc.get(session.id)).title).toBe("from sse")
expect((yield* sessionSvc.get(session.id).pipe(Effect.orDie)).title).toBe("from sse")
}),
)
expect(

View File

@@ -8,13 +8,12 @@ import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { PermissionID } from "../../src/permission/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Project } from "../../src/project/project"
import { Server } from "../../src/server/server"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { Session } from "@/session/session"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from "../../src/session/schema"
import { MessageV2 } from "../../src/session/message-v2"
import { Database } from "@/storage/db"
import { SessionMessageTable, SessionTable } from "@/session/session.sql"
@@ -55,7 +54,7 @@ function createSession(directory: string, input?: Session.CreateInput) {
)
}
function createTextMessage(directory: string, sessionID: SessionID, text: string) {
function createTextMessage(directory: string, sessionID: SessionIDType, text: string) {
return Effect.promise(
async () =>
await WithInstance.provide({
@@ -125,6 +124,10 @@ function json<T>(response: Response) {
})
}
function responseJson(response: Response) {
return Effect.promise(() => response.json())
}
function requestJson<T>(path: string, init?: RequestInit) {
return request(path, init).pipe(Effect.flatMap(json<T>))
}
@@ -147,6 +150,47 @@ afterEach(async () => {
})
describe("session HttpApi", () => {
it.live(
"returns declared not found errors for read routes",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const headers = { "x-opencode-directory": tmp.path }
const missingSession = SessionID.descending()
const missingSessionBody = {
name: "NotFoundError",
data: { message: `Session not found: ${missingSession}` },
}
const get = yield* request(pathFor(SessionPaths.get, { sessionID: missingSession }), { headers })
expect(get.status).toBe(404)
expect(yield* responseJson(get)).toEqual(missingSessionBody)
const messages = yield* request(pathFor(SessionPaths.messages, { sessionID: missingSession }), { headers })
expect(messages.status).toBe(404)
expect(yield* responseJson(messages)).toEqual(missingSessionBody)
const remove = yield* request(pathFor(SessionPaths.remove, { sessionID: missingSession }), {
headers,
method: "DELETE",
})
expect(remove.status).toBe(404)
expect(yield* responseJson(remove)).toEqual(missingSessionBody)
const session = yield* createSession(tmp.path, { title: "missing message" })
const missingMessage = MessageID.ascending()
const message = yield* request(
pathFor(SessionPaths.message, { sessionID: session.id, messageID: missingMessage }),
{ headers },
)
expect(message.status).toBe(404)
expect(yield* responseJson(message)).toEqual({
name: "NotFoundError",
data: { message: `Message not found: ${missingMessage}` },
})
}),
),
)
it.live(
"serves read routes through Hono bridge",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>