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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/opencode/specs/effect-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ Decision table for the design:
- MCP route handlers — migrated 2026-06-15. The current `server/instance/mcp.ts` operation handlers now run MCP service calls through `AppRuntime.runPromise(Effect.gen(...))` and `MCP.Service`, with route tests covering disabled local server add and non-OAuth auth 400 behavior. This does not change MCP service behavior, OAuth providers, or config schema.
- Workspace route handlers — migrated 2026-06-15. The current `server/instance/workspace.ts` create/list/status/remove handlers now run through `AppRuntime.runPromise(Effect.gen(...))` and `Workspace.Service`, with route tests covering the public create/list/status/remove HTTP behavior, current-project status filtering, and legacy worktree bad-request error mapping.
- Server routing/runtime helpers — migrated 2026-06-15. `server/proxy.ts` and `server/fence.ts` now read workspace connection status through `AppRuntime` and `Workspace.Service`, while `server/instance/workspace-routing.ts` resolves session-bound workspace ownership through the injected `Session.Service`. This keeps Hono routing behavior unchanged and leaves the service facades as legacy compatibility boundaries.
- Session route facade stragglers — migrated 2026-06-15. The current `server/instance/session.ts` owner now routes the listed GET `/session`, share/unshare session fetches, summarize post-loop message check, and deprecated session permission response through `AppRuntime.runPromise(Effect.gen(...))` with `Session.Service` / `Permission.Service`. This only clears those route-owner stragglers; it does not claim full `server/routes/session.ts` migration and does not touch session processor, prompt, or run-state internals.

## Route handler effectification

Expand Down Expand Up @@ -418,7 +419,7 @@ When migrating, always use `{ concurrency: "unbounded" }` with `Effect.all` —

Route files to convert (each handler that calls facades should be wrapped):

- [ ] `server/routes/session.ts` — heaviest; uses Session, SessionPrompt, SessionRevert, SessionCompaction, SessionShare, SessionSummary, SessionRunState, Agent, Permission, Bus
- [ ] `server/routes/session.ts` — heaviest; current owner is `server/instance/session.ts`. The 2026-06-15 straggler pass cleared GET `/session`, share/unshare `Session.get`, summarize `Session.messages`, and deprecated permission response `Permission.reply`; heavier SessionPrompt/SessionRevert/SessionShare/etc. route work remains out of scope.
- [ ] `server/routes/global.ts` — uses Config, Project, Provider, Vcs, Snapshot, Agent
- [x] `server/instance/provider.ts` — migrated 2026-06-15. Provider auth route bodies now yield `ProviderAuth.Service` inside `AppRuntime.runPromise(Effect.gen(...))`; the old `server/routes/provider.ts` checklist path is stale in the current tree.
- [ ] `server/routes/question.ts` — stale checklist path. The current tree has no `server/instance/question.ts` route; do not claim completion without a live route owner.
Expand Down
58 changes: 40 additions & 18 deletions packages/opencode/src/server/instance/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,17 +116,19 @@ export const SessionRoutes = lazy(() =>
),
async (c) => {
const query = c.req.valid("query")
const sessions: Session.Info[] = []
for await (const session of Session.list({
directory: query.directory,
roots: query.roots,
start: query.start,
search: query.search,
limit: query.limit,
sort: query.sort,
})) {
sessions.push(session)
}
const sessions = await AppRuntime.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
return yield* session.list({
Comment thread
Astro-Han marked this conversation as resolved.
Resolved
directory: query.directory,
roots: query.roots,
start: query.start,
search: query.search,
limit: query.limit,
sort: query.sort,
})
}),
)
return c.json(sessions)
},
)
Expand Down Expand Up @@ -650,7 +652,12 @@ export const SessionRoutes = lazy(() =>
return c.json({ error: "cloud_share_disabled" }, 410)
}
const share = await SessionShare.share(sessionID)
const session = await Session.get(sessionID)
const session = await AppRuntime.runPromise(
Effect.gen(function* () {
const sessions = yield* Session.Service
return yield* sessions.get(sessionID)
}),
)
return c.json({
...session,
share,
Expand Down Expand Up @@ -1040,7 +1047,12 @@ export const SessionRoutes = lazy(() =>
return c.json({ error: "cloud_share_disabled" }, 410)
}
await SessionShare.unshare(sessionID)
const session = await Session.get(sessionID)
const session = await AppRuntime.runPromise(
Effect.gen(function* () {
const sessions = yield* Session.Service
return yield* sessions.get(sessionID)
}),
)
return c.json({
...session,
share: undefined,
Expand Down Expand Up @@ -1114,7 +1126,12 @@ export const SessionRoutes = lazy(() =>
// resolve `true` for a session that visibly failed. Surface the
// error so SDK callers can branch on it. User-initiated aborts are
// not failures from the route's perspective.
const finalMsgs = await Session.messages({ sessionID })
const finalMsgs = await AppRuntime.runPromise(
Effect.gen(function* () {
const sessions = yield* Session.Service
return yield* sessions.messages({ sessionID })
}),
)
for (let i = finalMsgs.length - 1; i >= 0; i--) {
const info = finalMsgs[i].info
if (info.role !== "assistant" || info.mode !== "compaction") continue
Expand Down Expand Up @@ -1659,10 +1676,15 @@ export const SessionRoutes = lazy(() =>
const params = c.req.valid("param")
// Await so an unknown permission surfaces this route's documented 404
// instead of being swallowed by a fire-and-forget rejection.
await Permission.reply({
requestID: params.permissionID,
reply: c.req.valid("json").response,
})
await AppRuntime.runPromise(
Effect.gen(function* () {
const permission = yield* Permission.Service
yield* permission.reply({
requestID: params.permissionID,
reply: c.req.valid("json").response,
})
}),
)
return c.json(true)
},
),
Expand Down
35 changes: 22 additions & 13 deletions packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,15 @@ type GlobalListRow = SessionRow & {
lastUserMessageAt?: number | null
}
type ProjectFallback = { worktree?: string | null; vcs?: string | null }
type ListInput = {
directory?: string
workspaceID?: WorkspaceID
roots?: boolean
start?: number
search?: string
limit?: number
sort?: SessionListSort
}

function legacyExecutionContext(row: SessionRow, project: ProjectFallback | undefined) {
const ownerDirectoryRaw = project?.vcs === "git" ? (project.worktree ?? row.directory) : row.directory
Expand Down Expand Up @@ -514,6 +523,7 @@ export interface Interface {
}) => Effect.Effect<Info>
readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info>
readonly touch: (sessionID: SessionID) => Effect.Effect<void>
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
readonly get: (id: SessionID) => Effect.Effect<Info>
readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect<void>
readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect<void>
Expand Down Expand Up @@ -681,6 +691,11 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
return fromRow(row, project)
})

const list = Effect.fn("Session.list")(function* (input?: ListInput) {
const ctx = yield* InstanceState.context
return yield* Effect.sync(() => listForProject(ctx.project, input))
})

const children = Effect.fn("Session.children")(function* (parentID: SessionID) {
const rows = yield* db((d) =>
d
Expand Down Expand Up @@ -1143,6 +1158,7 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
getPart,
updatePartDelta,
findMessage,
list,
})
}),
)
Expand Down Expand Up @@ -1233,16 +1249,7 @@ const activitySelect = {
lastUserMessageAt: lastUserMessageAtExpr,
}

export function* list(input?: {
directory?: string
workspaceID?: WorkspaceID
roots?: boolean
start?: number
search?: string
limit?: number
sort?: SessionListSort
}) {
const project = Instance.project
function listForProject(project: { id: ProjectID }, input?: ListInput) {
const conditions = [eq(SessionTable.project_id, project.id)]

if (input?.workspaceID) {
Expand Down Expand Up @@ -1286,9 +1293,11 @@ export function* list(input?: {
)
for (const item of items) projects.set(item.id, item)
}
for (const row of rows) {
yield fromRow(row, projects.get(row.project_id))
}
return rows.map((row) => fromRow(row, projects.get(row.project_id)))
}

export function* list(input?: ListInput) {
yield* listForProject(Instance.project, input)
}

export function* listGlobal(input?: {
Expand Down
52 changes: 52 additions & 0 deletions packages/opencode/test/server/permission-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { PermissionID } from "../../src/permission/schema"
import { Instance } from "../../src/project/instance"
import { ErrorMiddleware } from "../../src/server/middleware"
import { PermissionRoutes } from "../../src/server/instance/permission"
import { SessionRoutes } from "../../src/server/instance/session"
import { SessionID } from "../../src/session/schema"
import { tmpdir } from "../fixture/fixture"

Expand All @@ -21,6 +22,12 @@ describe("permission routes", () => {
return instance
}

function sessionApp() {
const instance = new Hono().route("/session", SessionRoutes())
instance.onError(ErrorMiddleware)
return instance
}

test("replies to a pending permission through the route runtime", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
Expand Down Expand Up @@ -73,4 +80,49 @@ describe("permission routes", () => {
},
})
})

test("replies through the deprecated session permission route", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const sessionID = SessionID.descending()
const requestID = PermissionID.ascending()
const pending = await AppRuntime.runPromise(Deferred.make<void>())
const asked = AppRuntime.runPromise(
Permission.Service.use((permission) =>
permission.ask({
id: requestID,
sessionID,
permission: "bash",
patterns: ["echo ok"],
metadata: {},
always: ["echo ok"],
ruleset: [{ permission: "bash", pattern: "echo ok", action: "ask" }],
onPending: () => Deferred.succeed(pending, undefined),
}),
),
)

await AppRuntime.runPromise(Deferred.await(pending))

try {
const response = await sessionApp().request(`/session/${sessionID}/permissions/${requestID}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ response: "once" }),
})

expect(response.status).toBe(200)
expect(await response.json()).toBe(true)
await expect(asked).resolves.toBeUndefined()
} finally {
await AppRuntime.runPromise(
Permission.Service.use((permission) => permission.reply({ requestID, reply: "reject" })),
).catch(() => undefined)
await asked.catch(() => undefined)
}
},
})
})
})
10 changes: 3 additions & 7 deletions packages/opencode/test/server/session-runtime-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,10 @@ describe("session runtime routes", () => {
fn: async () => {
const session = await svc.create({})

const shared = { ...session, share: { url: "https://share.example/s/demo" } }
const unshared = { ...session, share: undefined }
const share = { url: "https://share.example/s/demo" }

const shareSpy = spyOn(SessionShare, "share").mockResolvedValue({ url: shared.share!.url } as any)
const shareSpy = spyOn(SessionShare, "share").mockResolvedValue(share as any)
const unshareSpy = spyOn(SessionShare, "unshare").mockResolvedValue(undefined as any)
const getSpy = spyOn(SessionNs, "get")
getSpy.mockResolvedValueOnce(shared as any)
getSpy.mockResolvedValueOnce(unshared as any)

const commandSpy = spyOn(SessionPrompt, "command").mockResolvedValue({
info: { id: "msg_command", sessionID: session.id, role: "assistant" },
Expand All @@ -58,7 +54,7 @@ describe("session runtime routes", () => {

const shareRes = await app.request(`/session/${session.id}/share`, { method: "POST" })
expect(shareRes.status).toBe(200)
expect((await shareRes.json()).share.url).toBe(shared.share!.url)
expect((await shareRes.json()).share.url).toBe(share.url)
expect(shareSpy).toHaveBeenCalledWith(session.id)

const unshareRes = await app.request(`/session/${session.id}/share`, { method: "DELETE" })
Expand Down
Loading