Type session not-found errors (#25818)

This commit is contained in:
Kit Langton
2026-05-05 21:33:47 -04:00
committed by GitHub
parent f5c3d352a1
commit 8555de8189
22 changed files with 623 additions and 132 deletions

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,9 @@ 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(NotFoundError.isInstance, () => fail(`Session not found: ${args.sessionID}`)),
)
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL)
}),
})

View File

@@ -24,11 +24,12 @@ import { Session } from "@/session/session"
import { SessionPrompt } from "@/session/prompt"
import { SessionTable } from "@/session/session.sql"
import { SessionID } from "@/session/schema"
import { NotFoundError } from "@/storage/storage"
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 +740,19 @@ 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.catchIf(NotFoundError.isInstance, () => Effect.void)),
{ 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

@@ -32,4 +32,6 @@ Avoid `HttpRouter.provideRequest(...)` unless the dependency is intentionally re
Use `Effect.provideService(...)` in middleware only for request-derived context, such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`. Do not use it to smuggle stable services through request effects when they can be yielded at layer construction.
Public JSON errors should be explicit `Schema.ErrorClass` contracts declared on each endpoint. Use built-in `HttpApiError.*` classes only when their empty/tagged body is the intended wire shape; for SDK-visible errors with messages, define an API error schema such as `ApiNotFoundError` and fail with that exact declared error. Keep domain and storage services free of HttpApi types, and translate expected domain errors at the handler boundary.
When adding middleware, compose it at the layer boundary and keep the route tree explicit in `server.ts`. Shared router middleware such as auth, workspace routing, and instance context should stay visible where routes are assembled.

View File

@@ -0,0 +1,18 @@
import { Schema } from "effect"
export class ApiNotFoundError extends Schema.ErrorClass<ApiNotFoundError>("NotFoundError")(
{
name: Schema.Literal("NotFoundError"),
data: Schema.Struct({
message: Schema.String,
}),
},
{ httpApiStatus: 404 },
) {}
export function notFound(message: string) {
return new ApiNotFoundError({
name: "NotFoundError",
data: { message },
})
}

View File

@@ -6,6 +6,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "e
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { ApiNotFoundError } from "../errors"
import { described } from "./metadata"
const root = "/pty"
@@ -64,7 +65,7 @@ export const PtyApi = HttpApi.make("pty")
HttpApiEndpoint.get("get", PtyPaths.get, {
params: { ptyID: PtyID },
success: described(Pty.Info, "Session info"),
error: HttpApiError.NotFound,
error: ApiNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.get",
@@ -76,7 +77,7 @@ export const PtyApi = HttpApi.make("pty")
params: { ptyID: PtyID },
payload: Pty.UpdateInput,
success: described(Pty.Info, "Updated session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.update",
@@ -87,7 +88,7 @@ export const PtyApi = HttpApi.make("pty")
HttpApiEndpoint.delete("remove", PtyPaths.remove, {
params: { ptyID: PtyID },
success: described(Schema.Boolean, "Session removed"),
error: HttpApiError.NotFound,
error: ApiNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.remove",
@@ -98,7 +99,7 @@ export const PtyApi = HttpApi.make("pty")
HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, {
params: { ptyID: PtyID },
success: described(PtyTicket.ConnectToken, "WebSocket connect token"),
error: [HttpApiError.Forbidden, HttpApiError.NotFound],
error: [HttpApiError.Forbidden, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.connectToken",

View File

@@ -15,6 +15,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, Op
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { ApiNotFoundError } from "../errors"
import { described } from "./metadata"
const root = "/session"
@@ -123,7 +124,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, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.get",
@@ -168,7 +169,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, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.messages",
@@ -179,7 +180,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, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.message",
@@ -201,7 +202,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, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.delete",
@@ -213,7 +214,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, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.update",
@@ -225,6 +226,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
payload: ForkPayload,
success: described(Session.Info, "200"),
error: ApiNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.fork",
@@ -259,7 +261,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, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.share",
@@ -270,7 +272,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, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.unshare",
@@ -282,7 +284,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, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.summarize",

View File

@@ -4,6 +4,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "e
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { ApiNotFoundError } from "../errors"
import { described } from "./metadata"
const root = "/tui"
@@ -155,7 +156,7 @@ export const TuiApi = HttpApi.make("tui")
HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, {
payload: TuiEvent.SessionSelect.properties,
success: described(Schema.Boolean, "Session selected successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.selectSession",

View File

@@ -15,6 +15,7 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
import { InstanceHttpApi } from "../api"
import * as ApiError from "../errors"
import { CursorQuery, Params, PtyPaths } from "../groups/pty"
import { WebSocketTracker } from "../websocket-tracker"
@@ -46,7 +47,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) {
const info = yield* pty.get(ctx.params.ptyID)
if (!info) return yield* new HttpApiError.NotFound({})
if (!info) return yield* ApiError.notFound("Session not found")
return info
})
@@ -58,7 +59,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
...ctx.payload,
size: ctx.payload.size ? { ...ctx.payload.size } : undefined,
})
if (!info) return yield* new HttpApiError.NotFound({})
if (!info) return yield* ApiError.notFound("Session not found")
return info
})
@@ -71,7 +72,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
const request = yield* HttpServerRequest.HttpServerRequest
if (request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(request, cors))
return yield* new HttpApiError.Forbidden({})
if (!(yield* pty.get(ctx.params.ptyID))) return yield* new HttpApiError.NotFound({})
if (!(yield* pty.get(ctx.params.ptyID))) return yield* ApiError.notFound("Session not found")
return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) })
})

View File

@@ -0,0 +1,9 @@
import type { NotFoundError as StorageNotFoundError } from "@/storage/storage"
import { Effect } from "effect"
import * as ApiError from "../errors"
type StorageNotFound = InstanceType<typeof StorageNotFoundError>
export function mapStorageNotFound<A, R>(self: Effect.Effect<A, StorageNotFound, R>) {
return self.pipe(Effect.mapError((error) => ApiError.notFound(error.data.message)))
}

View File

@@ -37,14 +37,7 @@ import {
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),
),
)
import * as SessionError from "./session-errors"
export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) =>
Effect.gen(function* () {
@@ -79,7 +72,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
})
const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) {
return yield* mapNotFound(session.get(ctx.params.sessionID))
return yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID))
})
const children = Effect.fn("SessionHttpApi.children")(function* (ctx: { params: { sessionID: SessionID } }) {
@@ -101,51 +94,49 @@ 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({}),
})
}
yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID))
if (ctx.query.limit === undefined || ctx.query.limit === 0) {
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
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 })),
return yield* SessionError.mapStorageNotFound(
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 +161,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* SessionError.mapStorageNotFound(session.remove(ctx.params.sessionID))
return true
})
@@ -178,7 +169,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* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID))
if (ctx.payload.title !== undefined) {
yield* session.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title })
}
@@ -191,14 +182,16 @@ 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* SessionError.mapStorageNotFound(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* SessionError.mapStorageNotFound(
session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }),
)
})
const abort = Effect.fn("SessionHttpApi.abort")(function* (ctx: { params: { sessionID: SessionID } }) {
@@ -222,19 +215,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* SessionError.mapStorageNotFound(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* SessionError.mapStorageNotFound(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* SessionError.mapStorageNotFound(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

@@ -1,13 +1,12 @@
import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { SessionTable } from "@/session/session.sql"
import * as Database from "@/storage/db"
import { eq } from "drizzle-orm"
import { Session } from "@/session/session"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { nextTuiRequest, submitTuiResponse } from "@/server/shared/tui-control"
import { InstanceHttpApi } from "../api"
import { CommandPayload, TuiPublishPayload } from "../groups/tui"
import * as SessionError from "./session-errors"
const commandAliases = {
session_new: "session.new",
@@ -28,6 +27,7 @@ const commandAliases = {
export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handlers) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const session = yield* Session.Service
const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command | undefined) =>
bus.publish(TuiEvent.CommandExecute, { command } as typeof TuiEvent.CommandExecute.properties.Type)
@@ -98,12 +98,7 @@ export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handler
payload: typeof TuiEvent.SessionSelect.properties.Type
}) {
if (!ctx.payload.sessionID.startsWith("ses")) return yield* new HttpApiError.BadRequest({})
const row = yield* Effect.sync(() =>
Database.use((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, ctx.payload.sessionID)).get(),
),
)
if (!row) return yield* new HttpApiError.NotFound({})
yield* SessionError.mapStorageNotFound(session.get(ctx.payload.sessionID))
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload)
return true
})

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,10 @@ 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(NotFoundError.isInstance, () => 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)