feat(httpapi): bridge experimental session list (#24478)
This commit is contained in:
@@ -181,7 +181,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
|
|||||||
| `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
|
| `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
|
||||||
| `workspace` | `bridged` partial | adaptor/list/status; create/remove/session-restore remain |
|
| `workspace` | `bridged` partial | adaptor/list/status; create/remove/session-restore remain |
|
||||||
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
|
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
|
||||||
| experimental JSON routes | `bridged` partial | console, tool, worktree list/mutations, resource list; global session list remains later |
|
| experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list |
|
||||||
| `session` | `later/special` | large stateful surface plus streaming |
|
| `session` | `later/special` | large stateful surface plus streaming |
|
||||||
| `sync` | `later` | process/control side effects |
|
| `sync` | `later` | process/control side effects |
|
||||||
| `event` | `special` | SSE |
|
| `event` | `special` | SSE |
|
||||||
@@ -266,7 +266,7 @@ This checklist tracks bridge parity only. Checked routes are available through t
|
|||||||
- [x] `POST /experimental/worktree` - create worktree.
|
- [x] `POST /experimental/worktree` - create worktree.
|
||||||
- [x] `DELETE /experimental/worktree` - remove worktree.
|
- [x] `DELETE /experimental/worktree` - remove worktree.
|
||||||
- [x] `POST /experimental/worktree/reset` - reset worktree.
|
- [x] `POST /experimental/worktree/reset` - reset worktree.
|
||||||
- [ ] `GET /experimental/session` - global session list.
|
- [x] `GET /experimental/session` - global session list.
|
||||||
- [x] `GET /experimental/resource` - MCP resources.
|
- [x] `GET /experimental/resource` - MCP resources.
|
||||||
|
|
||||||
### Workspace Routes
|
### Workspace Routes
|
||||||
@@ -351,7 +351,7 @@ Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays rev
|
|||||||
2. [x] Bridge MCP add/connect/disconnect routes.
|
2. [x] Bridge MCP add/connect/disconnect routes.
|
||||||
3. [x] Bridge MCP OAuth routes: start, callback, authenticate, remove.
|
3. [x] Bridge MCP OAuth routes: start, callback, authenticate, remove.
|
||||||
4. [x] Bridge experimental console switch and tool list routes.
|
4. [x] Bridge experimental console switch and tool list routes.
|
||||||
5. [ ] Bridge experimental global session list.
|
5. [x] Bridge experimental global session list.
|
||||||
6. [ ] Bridge workspace create/remove/session-restore routes.
|
6. [ ] Bridge workspace create/remove/session-restore routes.
|
||||||
7. [ ] Bridge sync start/replay/history routes.
|
7. [ ] Bridge sync start/replay/history routes.
|
||||||
8. [ ] Bridge session read routes: list, status, get, children, todo, diff, messages.
|
8. [ ] Bridge session read routes: list, status, get, children, todo, diff, messages.
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { InstanceState } from "@/effect"
|
|||||||
import { MCP } from "@/mcp"
|
import { MCP } from "@/mcp"
|
||||||
import { Project } from "@/project"
|
import { Project } from "@/project"
|
||||||
import { ProviderID, ModelID } from "@/provider/schema"
|
import { ProviderID, ModelID } from "@/provider/schema"
|
||||||
|
import { Session } from "@/session"
|
||||||
import { ToolRegistry } from "@/tool"
|
import { ToolRegistry } from "@/tool"
|
||||||
import * as EffectZod from "@/util/effect-zod"
|
import * as EffectZod from "@/util/effect-zod"
|
||||||
import { Worktree } from "@/worktree"
|
import { Worktree } from "@/worktree"
|
||||||
import { Effect, Layer, Option, Schema } from "effect"
|
import { Effect, Layer, Option, Schema } from "effect"
|
||||||
|
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
|
||||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "./auth"
|
import { Authorization } from "./auth"
|
||||||
|
|
||||||
@@ -50,6 +52,15 @@ const ToolListQuery = Schema.Struct({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const WorktreeList = Schema.Array(Schema.String).annotate({ identifier: "WorktreeList" })
|
const WorktreeList = Schema.Array(Schema.String).annotate({ identifier: "WorktreeList" })
|
||||||
|
const SessionListQuery = Schema.Struct({
|
||||||
|
directory: Schema.optional(Schema.String),
|
||||||
|
roots: Schema.optional(Schema.Literals(["true", "false"])),
|
||||||
|
start: Schema.optional(Schema.NumberFromString),
|
||||||
|
cursor: Schema.optional(Schema.NumberFromString),
|
||||||
|
search: Schema.optional(Schema.String),
|
||||||
|
limit: Schema.optional(Schema.NumberFromString),
|
||||||
|
archived: Schema.optional(Schema.Literals(["true", "false"])),
|
||||||
|
})
|
||||||
|
|
||||||
export const ExperimentalPaths = {
|
export const ExperimentalPaths = {
|
||||||
console: "/experimental/console",
|
console: "/experimental/console",
|
||||||
@@ -59,6 +70,7 @@ export const ExperimentalPaths = {
|
|||||||
toolIDs: "/experimental/tool/ids",
|
toolIDs: "/experimental/tool/ids",
|
||||||
worktree: "/experimental/worktree",
|
worktree: "/experimental/worktree",
|
||||||
worktreeReset: "/experimental/worktree/reset",
|
worktreeReset: "/experimental/worktree/reset",
|
||||||
|
session: "/experimental/session",
|
||||||
resource: "/experimental/resource",
|
resource: "/experimental/resource",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
@@ -154,6 +166,17 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
|||||||
description: "Reset a worktree branch to the primary default branch.",
|
description: "Reset a worktree branch to the primary default branch.",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
HttpApiEndpoint.get("session", ExperimentalPaths.session, {
|
||||||
|
query: SessionListQuery,
|
||||||
|
success: Schema.Array(Session.GlobalInfo),
|
||||||
|
}).annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "experimental.session.list",
|
||||||
|
summary: "List sessions",
|
||||||
|
description:
|
||||||
|
"Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
|
HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
|
||||||
success: Schema.Record(Schema.String, MCP.Resource),
|
success: Schema.Record(Schema.String, MCP.Resource),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
@@ -279,6 +302,28 @@ export const experimentalHandlers = Layer.unwrap(
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const session = Effect.fn("ExperimentalHttpApi.session")(function* (ctx: { query: typeof SessionListQuery.Type }) {
|
||||||
|
const limit = ctx.query.limit ?? 100
|
||||||
|
const sessions = Array.from(
|
||||||
|
Session.listGlobal({
|
||||||
|
directory: ctx.query.directory,
|
||||||
|
roots: ctx.query.roots === "true" ? true : undefined,
|
||||||
|
start: ctx.query.start,
|
||||||
|
cursor: ctx.query.cursor,
|
||||||
|
search: ctx.query.search,
|
||||||
|
limit: limit + 1,
|
||||||
|
archived: ctx.query.archived === "true" ? true : undefined,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const list = sessions.length > limit ? sessions.slice(0, limit) : sessions
|
||||||
|
return HttpServerResponse.jsonUnsafe(list, {
|
||||||
|
headers:
|
||||||
|
sessions.length > limit && list.length > 0
|
||||||
|
? { "x-next-cursor": String(list[list.length - 1].time.updated) }
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () {
|
const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () {
|
||||||
return yield* mcp.resources()
|
return yield* mcp.resources()
|
||||||
})
|
})
|
||||||
@@ -294,6 +339,7 @@ export const experimentalHandlers = Layer.unwrap(
|
|||||||
.handle("worktreeCreate", worktreeCreate)
|
.handle("worktreeCreate", worktreeCreate)
|
||||||
.handle("worktreeRemove", worktreeRemove)
|
.handle("worktreeRemove", worktreeRemove)
|
||||||
.handle("worktreeReset", worktreeReset)
|
.handle("worktreeReset", worktreeReset)
|
||||||
|
.handle("session", session)
|
||||||
.handle("resource", resource),
|
.handle("resource", resource),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
|||||||
app.post(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
|
app.post(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
|
||||||
app.delete(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
|
app.delete(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
|
||||||
app.post(ExperimentalPaths.worktreeReset, (c) => handler(c.req.raw, context))
|
app.post(ExperimentalPaths.worktreeReset, (c) => handler(c.req.raw, context))
|
||||||
|
app.get(ExperimentalPaths.session, (c) => handler(c.req.raw, context))
|
||||||
app.get(ExperimentalPaths.resource, (c) => handler(c.req.raw, context))
|
app.get(ExperimentalPaths.resource, (c) => handler(c.req.raw, context))
|
||||||
app.get("/provider", (c) => handler(c.req.raw, context))
|
app.get("/provider", (c) => handler(c.req.raw, context))
|
||||||
app.get("/provider/auth", (c) => handler(c.req.raw, context))
|
app.get("/provider/auth", (c) => handler(c.req.raw, context))
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { afterEach, describe, expect, test } from "bun:test"
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
import type { UpgradeWebSocket } from "hono/ws"
|
import type { UpgradeWebSocket } from "hono/ws"
|
||||||
|
import { Effect } from "effect"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { InstanceRoutes } from "../../src/server/routes/instance"
|
import { InstanceRoutes } from "../../src/server/routes/instance"
|
||||||
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental"
|
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental"
|
||||||
|
import { Session } from "../../src/session"
|
||||||
import { Database } from "../../src/storage"
|
import { Database } from "../../src/storage"
|
||||||
import { Log } from "../../src/util"
|
import { Log } from "../../src/util"
|
||||||
import { Worktree } from "../../src/worktree"
|
import { Worktree } from "../../src/worktree"
|
||||||
@@ -22,6 +24,14 @@ function app() {
|
|||||||
return InstanceRoutes(websocket)
|
return InstanceRoutes(websocket)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
|
||||||
|
return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSession(input?: Session.CreateInput) {
|
||||||
|
return runSession(Session.Service.use((svc) => svc.create(input)))
|
||||||
|
}
|
||||||
|
|
||||||
async function waitReady(directory: string) {
|
async function waitReady(directory: string) {
|
||||||
return await new Promise<void>((resolve, reject) => {
|
return await new Promise<void>((resolve, reject) => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -126,6 +136,43 @@ describe("experimental HttpApi", () => {
|
|||||||
expect(await switched.json()).toBe(true)
|
expect(await switched.json()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("serves global session list through Hono bridge", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||||
|
|
||||||
|
const first = await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => createSession({ title: "page-one" }),
|
||||||
|
})
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||||
|
const second = await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => createSession({ title: "page-two" }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const headers = { "x-opencode-directory": tmp.path }
|
||||||
|
const page = await app().request(
|
||||||
|
`${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "1" })}`,
|
||||||
|
{ headers },
|
||||||
|
)
|
||||||
|
expect(page.status).toBe(200)
|
||||||
|
expect(page.headers.get("x-next-cursor")).toBeTruthy()
|
||||||
|
|
||||||
|
const body = (await page.json()) as Session.GlobalInfo[]
|
||||||
|
expect(body.map((session) => session.id)).toEqual([second.id])
|
||||||
|
expect(body[0].project?.id).toBe(second.projectID)
|
||||||
|
|
||||||
|
const next = await app().request(
|
||||||
|
`${ExperimentalPaths.session}?${new URLSearchParams({
|
||||||
|
directory: tmp.path,
|
||||||
|
limit: "10",
|
||||||
|
cursor: body[0].time.updated.toString(),
|
||||||
|
})}`,
|
||||||
|
{ headers },
|
||||||
|
)
|
||||||
|
expect(next.status).toBe(200)
|
||||||
|
expect(((await next.json()) as Session.GlobalInfo[]).map((session) => session.id)).toContain(first.id)
|
||||||
|
})
|
||||||
|
|
||||||
testWorktreeMutations("serves worktree mutations through Hono bridge", async () => {
|
testWorktreeMutations("serves worktree mutations through Hono bridge", async () => {
|
||||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user