Browse Source

feat: add list sessions for all sessions

Ryan Vogel 2 tháng trước cách đây
mục cha
commit
c0e0156680

+ 49 - 0
packages/opencode/src/server/routes/global.ts

@@ -9,6 +9,7 @@ import { Installation } from "@/installation"
 import { Log } from "../../util/log"
 import { lazy } from "../../util/lazy"
 import { Config } from "../../config/config"
+import { Session } from "../../session"
 import { errors } from "../error"
 
 const log = Log.create({ service: "server" })
@@ -105,6 +106,54 @@ export const GlobalRoutes = lazy(() =>
         })
       },
     )
+    .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: "global.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)" }),
+          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 sessions: Session.GlobalInfo[] = []
+        for await (const session of Session.listGlobal({
+          directory: query.directory,
+          roots: query.roots,
+          start: query.start,
+          search: query.search,
+          limit: query.limit,
+          archived: query.archived,
+        })) {
+          sessions.push(session)
+        }
+        return c.json(sessions)
+      },
+    )
     .get(
       "/config",
       describeRoute({

+ 86 - 1
packages/opencode/src/session/index.ts

@@ -10,8 +10,10 @@ import { Flag } from "../flag/flag"
 import { Identifier } from "../id/id"
 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 } from "../storage/db"
+import type { SQL } from "../storage/db"
 import { SessionTable, MessageTable, PartTable } from "./session.sql"
+import { ProjectTable } from "../project/project.sql"
 import { Storage } from "@/storage/storage"
 import { Log } from "../util/log"
 import { MessageV2 } from "./message-v2"
@@ -154,6 +156,24 @@ export namespace Session {
     })
   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 = {
     Created: BusEvent.define(
       "session.created",
@@ -544,6 +564,71 @@ export namespace Session {
     }
   }
 
+  export function* listGlobal(input?: {
+    directory?: string
+    roots?: boolean
+    start?: 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?.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) => {
     const project = Instance.project
     const rows = Database.use((db) =>

+ 65 - 0
packages/opencode/test/server/global-session-list.test.ts

@@ -0,0 +1,65 @@
+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)
+  })
+})