feat(session): filter v2 session list by parent (#35037)
Co-authored-by: Dax <mail@thdxr.com>
This commit is contained in:
parent
7ebd344fa2
commit
6555df912a
17 changed files with 180 additions and 21 deletions
|
|
@ -45,6 +45,7 @@ type Endpoint4_0Input = {
|
|||
readonly limit?: Endpoint4_0Request["query"]["limit"]
|
||||
readonly order?: Endpoint4_0Request["query"]["order"]
|
||||
readonly search?: Endpoint4_0Request["query"]["search"]
|
||||
readonly parentID?: Endpoint4_0Request["query"]["parentID"]
|
||||
readonly directory?: Endpoint4_0Request["query"]["directory"]
|
||||
readonly project?: Endpoint4_0Request["query"]["project"]
|
||||
readonly subpath?: Endpoint4_0Request["query"]["subpath"]
|
||||
|
|
@ -57,6 +58,7 @@ const Endpoint4_0 = (raw: RawClient["server.session"]) => (input?: Endpoint4_0In
|
|||
limit: input?.["limit"],
|
||||
order: input?.["order"],
|
||||
search: input?.["search"],
|
||||
parentID: input?.["parentID"],
|
||||
directory: input?.["directory"],
|
||||
project: input?.["project"],
|
||||
subpath: input?.["subpath"],
|
||||
|
|
|
|||
|
|
@ -356,6 +356,7 @@ export function make(options: ClientOptions) {
|
|||
limit: input?.["limit"],
|
||||
order: input?.["order"],
|
||||
search: input?.["search"],
|
||||
parentID: input?.["parentID"],
|
||||
directory: input?.["directory"],
|
||||
project: input?.["project"],
|
||||
subpath: input?.["subpath"],
|
||||
|
|
@ -1361,7 +1362,11 @@ function encodePath(value: string): string {
|
|||
}
|
||||
|
||||
function appendQuery(params: URLSearchParams, key: string, value: unknown): void {
|
||||
if (value === undefined || value === null) return
|
||||
if (value === undefined) return
|
||||
if (value === null) {
|
||||
params.append(key, "null")
|
||||
return
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) appendQuery(params, key, item)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -218,6 +218,7 @@ export type SessionListInput = {
|
|||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: string | null | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
|
|
@ -228,6 +229,7 @@ export type SessionListInput = {
|
|||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: string | null | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
|
|
@ -238,6 +240,7 @@ export type SessionListInput = {
|
|||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: string | null | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
|
|
@ -248,16 +251,29 @@ export type SessionListInput = {
|
|||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: string | null | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
readonly cursor?: string | undefined
|
||||
}["search"]
|
||||
readonly parentID?: {
|
||||
readonly workspace?: string | undefined
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: string | null | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
readonly cursor?: string | undefined
|
||||
}["parentID"]
|
||||
readonly directory?: {
|
||||
readonly workspace?: string | undefined
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: string | null | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
|
|
@ -268,6 +284,7 @@ export type SessionListInput = {
|
|||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: string | null | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
|
|
@ -278,6 +295,7 @@ export type SessionListInput = {
|
|||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: string | null | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
|
|
@ -288,6 +306,7 @@ export type SessionListInput = {
|
|||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: string | null | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
|
|
|
|||
|
|
@ -247,7 +247,7 @@ test("session methods use the public HTTP contract", async () => {
|
|||
},
|
||||
})
|
||||
|
||||
const page = await client.session.list({ limit: 10, order: "desc" })
|
||||
const page = await client.session.list({ limit: 10, order: "desc", parentID: null })
|
||||
const active = await client.session.active()
|
||||
const created = await client.session.create({ location: { directory: "/tmp/project" } })
|
||||
await client.session.switchAgent({ sessionID: "ses_test", agent: "build" })
|
||||
|
|
@ -276,7 +276,7 @@ test("session methods use the public HTTP contract", async () => {
|
|||
expect(log).toEqual([modelSwitchedEvent, caughtUp])
|
||||
expect(message).toEqual(modelSwitchedMessage)
|
||||
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
|
||||
["GET", "http://localhost:3000/api/session?limit=10&order=desc"],
|
||||
["GET", "http://localhost:3000/api/session?limit=10&order=desc&parentID=null"],
|
||||
["GET", "http://localhost:3000/api/session/active"],
|
||||
["POST", "http://localhost:3000/api/session"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/agent"],
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ export * from "./session/schema"
|
|||
|
||||
import { DateTime, Effect, Layer, Schema, Context, Stream, Scope } from "effect"
|
||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||
import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { ProjectV2 } from "./project"
|
||||
import { WorkspaceV2 } from "./workspace"
|
||||
import { ModelV2 } from "./model"
|
||||
|
|
@ -61,6 +61,7 @@ const ListInputBase = {
|
|||
search: Schema.String.pipe(Schema.optional),
|
||||
limit: PositiveInt.pipe(Schema.optional),
|
||||
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
|
||||
parentID: Schema.NullOr(SessionSchema.ID).pipe(Schema.optional),
|
||||
anchor: ListAnchor.pipe(Schema.optional),
|
||||
}
|
||||
|
||||
|
|
@ -358,12 +359,16 @@ const layer = Layer.effect(
|
|||
const direction = input.anchor?.direction ?? "next"
|
||||
const requestedOrder = input.order ?? "desc"
|
||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||
const sortColumn = SessionTable.time_created
|
||||
const sortColumn = SessionTable.time_updated
|
||||
const conditions: SQL[] = []
|
||||
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
|
||||
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
|
||||
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
|
||||
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||
if (input.parentID !== undefined)
|
||||
conditions.push(
|
||||
input.parentID === null ? isNull(SessionTable.parent_id) : eq(SessionTable.parent_id, input.parentID),
|
||||
)
|
||||
if (input.anchor) {
|
||||
conditions.push(
|
||||
order === "asc"
|
||||
|
|
|
|||
|
|
@ -130,6 +130,53 @@ describe("SessionV2.create", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("filters root sessions before applying the page limit", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
const staleRoot = yield* session.create({ location, title: "stale root" })
|
||||
const root = yield* session.create({ location, title: "root" })
|
||||
const children = yield* Effect.forEach(Array.from({ length: 60 }), (_, index) =>
|
||||
session.create({ parentID: root.id, title: `child ${index}` }),
|
||||
)
|
||||
|
||||
yield* Effect.forEach(children, (item, index) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ time_created: index + 100, time_updated: index + 20_000 })
|
||||
.where(eq(SessionTable.id, item.id))
|
||||
.run(),
|
||||
)
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ time_created: 2, time_updated: 5_000 })
|
||||
.where(eq(SessionTable.id, staleRoot.id))
|
||||
.run()
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ time_created: 1, time_updated: 10_000 })
|
||||
.where(eq(SessionTable.id, root.id))
|
||||
.run()
|
||||
|
||||
const page = yield* session.list({ directory: location.directory, parentID: null, limit: 1, order: "desc" })
|
||||
|
||||
expect(page.data.map((item) => item.id)).toEqual([root.id])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters direct child sessions by parent ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const parent = yield* session.create({ location, title: "parent" })
|
||||
const child = yield* session.create({ parentID: parent.id, title: "child" })
|
||||
yield* session.create({ location, title: "other root" })
|
||||
|
||||
const page = yield* session.list({ parentID: parent.id })
|
||||
|
||||
expect(page.data.map((item) => item.id)).toEqual([child.id])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forks a session by replaying a durable fork event into copied projected rows", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -521,6 +521,39 @@ describe("HttpApiCodegen.generate", () => {
|
|||
}
|
||||
})
|
||||
|
||||
test("serializes explicit null query values", async () => {
|
||||
const output = emitPromise(
|
||||
compileContract(
|
||||
api(
|
||||
HttpApiEndpoint.get("list", "/session", {
|
||||
query: { parentID: Schema.optional(Schema.NullOr(Schema.String)) },
|
||||
success: Schema.Struct({ data: Schema.Array(Schema.String) }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
|
||||
|
||||
try {
|
||||
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
|
||||
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
|
||||
let request: Request | undefined
|
||||
const client = generated.OpenCode.make({
|
||||
baseUrl: "https://example.com",
|
||||
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
request = input instanceof Request ? input : new Request(input, init)
|
||||
return Response.json({ data: [] })
|
||||
},
|
||||
})
|
||||
|
||||
await client.session.list({ parentID: null })
|
||||
|
||||
expect(request?.url).toBe("https://example.com/session?parentID=null")
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects with declared tagged errors and exports a type guard", async () => {
|
||||
const output = emitPromise(
|
||||
compileContract(
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export type Endpoint4_0Input = {
|
|||
readonly limit?: Endpoint4_0Request["query"]["limit"]
|
||||
readonly order?: Endpoint4_0Request["query"]["order"]
|
||||
readonly search?: Endpoint4_0Request["query"]["search"]
|
||||
readonly parentID?: Endpoint4_0Request["query"]["parentID"]
|
||||
readonly directory?: Endpoint4_0Request["query"]["directory"]
|
||||
readonly project?: Endpoint4_0Request["query"]["project"]
|
||||
readonly subpath?: Endpoint4_0Request["query"]["subpath"]
|
||||
|
|
|
|||
|
|
@ -29,6 +29,18 @@ import { Revert } from "@opencode-ai/schema/revert"
|
|||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
import { EventLog } from "@opencode-ai/schema/event-log"
|
||||
|
||||
const ParentIDFilter = Schema.Union([
|
||||
Session.ID,
|
||||
Schema.Null.pipe(
|
||||
Schema.encodeTo(Schema.Literal("null"), {
|
||||
decode: SchemaGetter.transform(() => null),
|
||||
encode: SchemaGetter.transform(() => "null" as const),
|
||||
}),
|
||||
),
|
||||
]).annotate({
|
||||
description: "Filter by parent session. Use null to return only root sessions.",
|
||||
})
|
||||
|
||||
const SessionsQueryFields = {
|
||||
workspace: Workspace.ID.pipe(Schema.optional),
|
||||
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({
|
||||
|
|
@ -38,6 +50,7 @@ const SessionsQueryFields = {
|
|||
description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
|
||||
}),
|
||||
search: Schema.optional(Schema.String),
|
||||
parentID: ParentIDFilter.pipe(Schema.optional),
|
||||
}
|
||||
|
||||
const SessionsDirectoryQuery = Schema.Struct({
|
||||
|
|
|
|||
|
|
@ -104,6 +104,19 @@ if (logTypesPatched === generatedTypes) {
|
|||
}
|
||||
await Bun.write("./src/v2/gen/types.gen.ts", logTypesPatched)
|
||||
|
||||
const querySerializerPath = "./src/v2/gen/client/utils.gen.ts"
|
||||
const querySerializerSource = await Bun.file(querySerializerPath).text()
|
||||
const querySerializerPatched = querySerializerSource.replace(
|
||||
/if \(value === undefined \|\| value === null\) \{\s*continue;?\s*\}/,
|
||||
"if (value === undefined) {\n continue;\n }\n\n if (value === null) {\n search.push(`${name}=null`);\n continue;\n }",
|
||||
)
|
||||
if (querySerializerPatched === querySerializerSource) {
|
||||
throw new Error(
|
||||
`Query serializer null patch did not apply; @hey-api/openapi-ts output may have changed (${querySerializerPath})`,
|
||||
)
|
||||
}
|
||||
await Bun.write(querySerializerPath, querySerializerPatched)
|
||||
|
||||
const generatedSdk = await Bun.file("./src/v2/gen/sdk.gen.ts").text()
|
||||
const logSdkPatched = generatedSdk.replace(
|
||||
/(Read the session log[\s\S]*?parameters: \{[\s\S]*?after\?: )string(\s*\|\s*null)?/,
|
||||
|
|
@ -196,5 +209,7 @@ function inlineTypedAllOfConstraints(value: unknown): void {
|
|||
|
||||
function isConstraintSchema(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return false
|
||||
return !Object.keys(value).some((key) => key === "$ref" || key === "type" || key === "allOf" || key === "anyOf" || key === "oneOf")
|
||||
return !Object.keys(value).some(
|
||||
(key) => key === "$ref" || key === "type" || key === "allOf" || key === "anyOf" || key === "oneOf",
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,12 @@ export const createQuerySerializer = <T = unknown>({ parameters = {}, ...args }:
|
|||
for (const name in queryParams) {
|
||||
const value = queryParams[name]
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
if (value === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
search.push(`${name}=null`)
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5742,6 +5742,7 @@ export class Session3 extends HeyApiClient {
|
|||
limit?: string | null
|
||||
order?: "asc" | "desc" | null
|
||||
search?: string | null
|
||||
parentID?: string | "null" | null
|
||||
directory?: string | null
|
||||
project?: string | null
|
||||
subpath?: string | null
|
||||
|
|
@ -5758,6 +5759,7 @@ export class Session3 extends HeyApiClient {
|
|||
{ in: "query", key: "limit" },
|
||||
{ in: "query", key: "order" },
|
||||
{ in: "query", key: "search" },
|
||||
{ in: "query", key: "parentID" },
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "project" },
|
||||
{ in: "query", key: "subpath" },
|
||||
|
|
|
|||
|
|
@ -12540,6 +12540,7 @@ export type V2SessionListData = {
|
|||
*/
|
||||
order?: "asc" | "desc" | null
|
||||
search?: string | null
|
||||
parentID?: string | "null" | null
|
||||
directory?: string | null
|
||||
project?: string | null
|
||||
subpath?: string | null
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||
...query,
|
||||
anchor: {
|
||||
id: first.id,
|
||||
time: DateTime.toEpochMillis(first.time.created),
|
||||
time: DateTime.toEpochMillis(first.time.updated),
|
||||
direction: "previous",
|
||||
},
|
||||
})
|
||||
|
|
@ -61,7 +61,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||
...query,
|
||||
anchor: {
|
||||
id: last.id,
|
||||
time: DateTime.toEpochMillis(last.time.created),
|
||||
time: DateTime.toEpochMillis(last.time.updated),
|
||||
direction: "next",
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -531,23 +531,33 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||
|
||||
let continued = false
|
||||
createEffect(() => {
|
||||
// When using -c, session list is loaded in blocking phase, so we can navigate at "partial"
|
||||
if (continued || sync.status === "loading" || !args.continue) return
|
||||
const match = data.session.list().find((session) => !session.parentID)?.id
|
||||
if (match) {
|
||||
continued = true
|
||||
if (args.fork) {
|
||||
continued = true
|
||||
const location = data.location.default()
|
||||
void sdk.api.session
|
||||
.list({
|
||||
limit: 1,
|
||||
order: "desc",
|
||||
parentID: null,
|
||||
directory: location.directory,
|
||||
workspace: location.workspaceID,
|
||||
})
|
||||
.then((response) => {
|
||||
const match = response.data[0]?.id
|
||||
if (!match) return
|
||||
if (!args.fork) {
|
||||
route.navigate({ type: "session", sessionID: match })
|
||||
return
|
||||
}
|
||||
void sdk.client.session.fork({ sessionID: match }).then((result) => {
|
||||
if (result.data?.id) {
|
||||
route.navigate({ type: "session", sessionID: result.data.id })
|
||||
} else {
|
||||
toast.show({ message: "Failed to fork session", variant: "error" })
|
||||
return
|
||||
}
|
||||
toast.show({ message: "Failed to fork session", variant: "error" })
|
||||
})
|
||||
} else {
|
||||
route.navigate({ type: "session", sessionID: match })
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(toast.error)
|
||||
})
|
||||
|
||||
// Handle --session with --fork: wait for sync to be fully complete before forking
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export function DialogSessionList() {
|
|||
search: query,
|
||||
limit: 50,
|
||||
order: "desc",
|
||||
parentID: null,
|
||||
directory: location.directory,
|
||||
workspace: location.workspaceID,
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue