feat: add list sessions for all sessions (experimental) (#14038)
This commit is contained in:
@@ -6,6 +6,7 @@ import { Worktree } from "../../worktree"
|
|||||||
import { Instance } from "../../project/instance"
|
import { Instance } from "../../project/instance"
|
||||||
import { Project } from "../../project/project"
|
import { Project } from "../../project/project"
|
||||||
import { MCP } from "../../mcp"
|
import { MCP } from "../../mcp"
|
||||||
|
import { Session } from "../../session"
|
||||||
import { zodToJsonSchema } from "zod-to-json-schema"
|
import { zodToJsonSchema } from "zod-to-json-schema"
|
||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
import { lazy } from "../../util/lazy"
|
import { lazy } from "../../util/lazy"
|
||||||
@@ -184,6 +185,65 @@ export const ExperimentalRoutes = lazy(() =>
|
|||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
.get(
|
||||||
|
"/session",
|
||||||
|
describeRoute({
|
||||||
|
summary: "List sessions",
|
||||||
|
description:
|
||||||
|
"Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.",
|
||||||
|
operationId: "experimental.session.list",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "List of sessions",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(Session.GlobalInfo.array()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
|
||||||
|
roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }),
|
||||||
|
start: z.coerce
|
||||||
|
.number()
|
||||||
|
.optional()
|
||||||
|
.meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }),
|
||||||
|
cursor: z.coerce
|
||||||
|
.number()
|
||||||
|
.optional()
|
||||||
|
.meta({ description: "Return sessions updated before this timestamp (milliseconds since epoch)" }),
|
||||||
|
search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }),
|
||||||
|
limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }),
|
||||||
|
archived: z.coerce.boolean().optional().meta({ description: "Include archived sessions (default false)" }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const query = c.req.valid("query")
|
||||||
|
const limit = query.limit ?? 100
|
||||||
|
const sessions: Session.GlobalInfo[] = []
|
||||||
|
for await (const session of Session.listGlobal({
|
||||||
|
directory: query.directory,
|
||||||
|
roots: query.roots,
|
||||||
|
start: query.start,
|
||||||
|
cursor: query.cursor,
|
||||||
|
search: query.search,
|
||||||
|
limit: limit + 1,
|
||||||
|
archived: query.archived,
|
||||||
|
})) {
|
||||||
|
sessions.push(session)
|
||||||
|
}
|
||||||
|
const hasMore = sessions.length > limit
|
||||||
|
const list = hasMore ? sessions.slice(0, limit) : sessions
|
||||||
|
if (hasMore && list.length > 0) {
|
||||||
|
c.header("x-next-cursor", String(list[list.length - 1].time.updated))
|
||||||
|
}
|
||||||
|
return c.json(list)
|
||||||
|
},
|
||||||
|
)
|
||||||
.get(
|
.get(
|
||||||
"/resource",
|
"/resource",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import { Flag } from "../flag/flag"
|
|||||||
import { Identifier } from "../id/id"
|
import { Identifier } from "../id/id"
|
||||||
import { Installation } from "../installation"
|
import { Installation } from "../installation"
|
||||||
|
|
||||||
import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like } from "../storage/db"
|
import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray, lt } from "../storage/db"
|
||||||
|
import type { SQL } from "../storage/db"
|
||||||
import { SessionTable, MessageTable, PartTable } from "./session.sql"
|
import { SessionTable, MessageTable, PartTable } from "./session.sql"
|
||||||
|
import { ProjectTable } from "../project/project.sql"
|
||||||
import { Storage } from "@/storage/storage"
|
import { Storage } from "@/storage/storage"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { MessageV2 } from "./message-v2"
|
import { MessageV2 } from "./message-v2"
|
||||||
@@ -154,6 +156,24 @@ export namespace Session {
|
|||||||
})
|
})
|
||||||
export type Info = z.output<typeof Info>
|
export type Info = z.output<typeof Info>
|
||||||
|
|
||||||
|
export const ProjectInfo = z
|
||||||
|
.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
worktree: z.string(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "ProjectSummary",
|
||||||
|
})
|
||||||
|
export type ProjectInfo = z.output<typeof ProjectInfo>
|
||||||
|
|
||||||
|
export const GlobalInfo = Info.extend({
|
||||||
|
project: ProjectInfo.nullable(),
|
||||||
|
}).meta({
|
||||||
|
ref: "GlobalSession",
|
||||||
|
})
|
||||||
|
export type GlobalInfo = z.output<typeof GlobalInfo>
|
||||||
|
|
||||||
export const Event = {
|
export const Event = {
|
||||||
Created: BusEvent.define(
|
Created: BusEvent.define(
|
||||||
"session.created",
|
"session.created",
|
||||||
@@ -544,6 +564,75 @@ export namespace Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function* listGlobal(input?: {
|
||||||
|
directory?: string
|
||||||
|
roots?: boolean
|
||||||
|
start?: number
|
||||||
|
cursor?: number
|
||||||
|
search?: string
|
||||||
|
limit?: number
|
||||||
|
archived?: boolean
|
||||||
|
}) {
|
||||||
|
const conditions: SQL[] = []
|
||||||
|
|
||||||
|
if (input?.directory) {
|
||||||
|
conditions.push(eq(SessionTable.directory, input.directory))
|
||||||
|
}
|
||||||
|
if (input?.roots) {
|
||||||
|
conditions.push(isNull(SessionTable.parent_id))
|
||||||
|
}
|
||||||
|
if (input?.start) {
|
||||||
|
conditions.push(gte(SessionTable.time_updated, input.start))
|
||||||
|
}
|
||||||
|
if (input?.cursor) {
|
||||||
|
conditions.push(lt(SessionTable.time_updated, input.cursor))
|
||||||
|
}
|
||||||
|
if (input?.search) {
|
||||||
|
conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||||
|
}
|
||||||
|
if (!input?.archived) {
|
||||||
|
conditions.push(isNull(SessionTable.time_archived))
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = input?.limit ?? 100
|
||||||
|
|
||||||
|
const rows = Database.use((db) => {
|
||||||
|
const query =
|
||||||
|
conditions.length > 0
|
||||||
|
? db
|
||||||
|
.select()
|
||||||
|
.from(SessionTable)
|
||||||
|
.where(and(...conditions))
|
||||||
|
: db.select().from(SessionTable)
|
||||||
|
return query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)).limit(limit).all()
|
||||||
|
})
|
||||||
|
|
||||||
|
const ids = [...new Set(rows.map((row) => row.project_id))]
|
||||||
|
const projects = new Map<string, ProjectInfo>()
|
||||||
|
|
||||||
|
if (ids.length > 0) {
|
||||||
|
const items = Database.use((db) =>
|
||||||
|
db
|
||||||
|
.select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree })
|
||||||
|
.from(ProjectTable)
|
||||||
|
.where(inArray(ProjectTable.id, ids))
|
||||||
|
.all(),
|
||||||
|
)
|
||||||
|
for (const item of items) {
|
||||||
|
projects.set(item.id, {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name ?? undefined,
|
||||||
|
worktree: item.worktree,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const project = projects.get(row.project_id) ?? null
|
||||||
|
yield { ...fromRow(row), project }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const children = fn(Identifier.schema("session"), async (parentID) => {
|
export const children = fn(Identifier.schema("session"), async (parentID) => {
|
||||||
const project = Instance.project
|
const project = Instance.project
|
||||||
const rows = Database.use((db) =>
|
const rows = Database.use((db) =>
|
||||||
|
|||||||
89
packages/opencode/test/server/global-session-list.test.ts
Normal file
89
packages/opencode/test/server/global-session-list.test.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { Project } from "../../src/project/project"
|
||||||
|
import { Session } from "../../src/session"
|
||||||
|
import { Log } from "../../src/util/log"
|
||||||
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
|
Log.init({ print: false })
|
||||||
|
|
||||||
|
describe("Session.listGlobal", () => {
|
||||||
|
test("lists sessions across projects with project metadata", async () => {
|
||||||
|
await using first = await tmpdir({ git: true })
|
||||||
|
await using second = await tmpdir({ git: true })
|
||||||
|
|
||||||
|
const firstSession = await Instance.provide({
|
||||||
|
directory: first.path,
|
||||||
|
fn: async () => Session.create({ title: "first-session" }),
|
||||||
|
})
|
||||||
|
const secondSession = await Instance.provide({
|
||||||
|
directory: second.path,
|
||||||
|
fn: async () => Session.create({ title: "second-session" }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const sessions = [...Session.listGlobal({ limit: 200 })]
|
||||||
|
const ids = sessions.map((session) => session.id)
|
||||||
|
|
||||||
|
expect(ids).toContain(firstSession.id)
|
||||||
|
expect(ids).toContain(secondSession.id)
|
||||||
|
|
||||||
|
const firstProject = Project.get(firstSession.projectID)
|
||||||
|
const secondProject = Project.get(secondSession.projectID)
|
||||||
|
|
||||||
|
const firstItem = sessions.find((session) => session.id === firstSession.id)
|
||||||
|
const secondItem = sessions.find((session) => session.id === secondSession.id)
|
||||||
|
|
||||||
|
expect(firstItem?.project?.id).toBe(firstProject?.id)
|
||||||
|
expect(firstItem?.project?.worktree).toBe(firstProject?.worktree)
|
||||||
|
expect(secondItem?.project?.id).toBe(secondProject?.id)
|
||||||
|
expect(secondItem?.project?.worktree).toBe(secondProject?.worktree)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("excludes archived sessions by default", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
|
||||||
|
const archived = await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => Session.create({ title: "archived-session" }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => Session.setArchived({ sessionID: archived.id, time: Date.now() }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const sessions = [...Session.listGlobal({ limit: 200 })]
|
||||||
|
const ids = sessions.map((session) => session.id)
|
||||||
|
|
||||||
|
expect(ids).not.toContain(archived.id)
|
||||||
|
|
||||||
|
const allSessions = [...Session.listGlobal({ limit: 200, archived: true })]
|
||||||
|
const allIds = allSessions.map((session) => session.id)
|
||||||
|
|
||||||
|
expect(allIds).toContain(archived.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("supports cursor pagination", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
|
||||||
|
const first = await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => Session.create({ title: "page-one" }),
|
||||||
|
})
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||||
|
const second = await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => Session.create({ title: "page-two" }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const page = [...Session.listGlobal({ directory: tmp.path, limit: 1 })]
|
||||||
|
expect(page.length).toBe(1)
|
||||||
|
expect(page[0].id).toBe(second.id)
|
||||||
|
|
||||||
|
const next = [...Session.listGlobal({ directory: tmp.path, limit: 10, cursor: page[0].time.updated })]
|
||||||
|
const ids = next.map((session) => session.id)
|
||||||
|
|
||||||
|
expect(ids).toContain(first.id)
|
||||||
|
expect(ids).not.toContain(second.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user