refactor(core): simplify session pagination
This commit is contained in:
@@ -15,7 +15,7 @@ import { Database } from "./database/database"
|
|||||||
import { SessionProjector } from "./session/projector"
|
import { SessionProjector } from "./session/projector"
|
||||||
import { SessionMessageTable, SessionTable } from "./session/sql"
|
import { SessionMessageTable, SessionTable } from "./session/sql"
|
||||||
import { SessionSchema } from "./session/schema"
|
import { SessionSchema } from "./session/schema"
|
||||||
import { AbsolutePath, RelativePath } from "./schema"
|
import { AbsolutePath, PositiveInt, RelativePath } from "./schema"
|
||||||
import { AgentV2 } from "./agent"
|
import { AgentV2 } from "./agent"
|
||||||
|
|
||||||
// get project -> project.locations
|
// get project -> project.locations
|
||||||
@@ -27,35 +27,35 @@ import { AgentV2 } from "./agent"
|
|||||||
// - by subpath
|
// - by subpath
|
||||||
// - by workspace (home is special)
|
// - by workspace (home is special)
|
||||||
|
|
||||||
export const ListCursor = Schema.Struct({
|
export const ListAnchor = Schema.Struct({
|
||||||
id: SessionSchema.ID,
|
id: SessionSchema.ID,
|
||||||
time: Schema.Finite,
|
time: Schema.Finite,
|
||||||
direction: Schema.Literals(["previous", "next"]),
|
direction: Schema.Literals(["previous", "next"]),
|
||||||
})
|
})
|
||||||
export type ListCursor = typeof ListCursor.Type
|
export type ListAnchor = typeof ListAnchor.Type
|
||||||
|
|
||||||
const ListInputBase = {
|
const ListInputBase = {
|
||||||
workspaceID: WorkspaceV2.ID.pipe(Schema.optional),
|
workspaceID: WorkspaceV2.ID.pipe(Schema.optional),
|
||||||
search: Schema.String.pipe(Schema.optional),
|
search: Schema.String.pipe(Schema.optional),
|
||||||
limit: Schema.Int.pipe(Schema.optional),
|
limit: PositiveInt.pipe(Schema.optional),
|
||||||
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
|
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
|
||||||
cursor: ListCursor.pipe(Schema.optional),
|
anchor: ListAnchor.pipe(Schema.optional),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ListInput = Schema.Union([
|
const ListDirectoryInput = Schema.Struct({
|
||||||
Schema.Struct({
|
...ListInputBase,
|
||||||
...ListInputBase,
|
directory: AbsolutePath,
|
||||||
}),
|
})
|
||||||
Schema.Struct({
|
|
||||||
...ListInputBase,
|
const ListProjectInput = Schema.Struct({
|
||||||
directory: AbsolutePath,
|
...ListInputBase,
|
||||||
}),
|
project: ProjectV2.ID,
|
||||||
Schema.Struct({
|
subpath: RelativePath.pipe(Schema.optional),
|
||||||
...ListInputBase,
|
})
|
||||||
project: ProjectV2.ID,
|
|
||||||
subpath: RelativePath.pipe(Schema.optional),
|
const ListAllInput = Schema.Struct(ListInputBase)
|
||||||
}),
|
|
||||||
])
|
export const ListInput = Schema.Union([ListDirectoryInput, ListProjectInput, ListAllInput])
|
||||||
export type ListInput = typeof ListInput.Type
|
export type ListInput = typeof ListInput.Type
|
||||||
|
|
||||||
type CreateInput = {
|
type CreateInput = {
|
||||||
@@ -205,25 +205,25 @@ export const layer = Layer.effect(
|
|||||||
return fromRow(row)
|
return fromRow(row)
|
||||||
}),
|
}),
|
||||||
list: Effect.fn("V2Session.list")(function* (input = {}) {
|
list: Effect.fn("V2Session.list")(function* (input = {}) {
|
||||||
const direction = input.cursor?.direction ?? "next"
|
const direction = input.anchor?.direction ?? "next"
|
||||||
const requestedOrder = input.order ?? "desc"
|
const requestedOrder = input.order ?? "desc"
|
||||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||||
const sortColumn = SessionTable.time_updated
|
const sortColumn = SessionTable.time_created
|
||||||
const conditions: SQL[] = []
|
const conditions: SQL[] = []
|
||||||
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
|
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
|
||||||
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
|
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
|
||||||
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
|
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
|
||||||
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
|
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||||
if (input.cursor) {
|
if (input.anchor) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
order === "asc"
|
order === "asc"
|
||||||
? or(
|
? or(
|
||||||
gt(sortColumn, input.cursor.time),
|
gt(sortColumn, input.anchor.time),
|
||||||
and(eq(sortColumn, input.cursor.time), gt(SessionTable.id, input.cursor.id)),
|
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
|
||||||
)!
|
)!
|
||||||
: or(
|
: or(
|
||||||
lt(sortColumn, input.cursor.time),
|
lt(sortColumn, input.anchor.time),
|
||||||
and(eq(sortColumn, input.cursor.time), lt(SessionTable.id, input.cursor.id)),
|
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
|
||||||
)!,
|
)!,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import { SessionID } from "@/session/schema"
|
|||||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { Schema } from "effect"
|
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||||
|
import { AbsolutePath, PositiveInt, RelativePath, withStatics } from "@opencode-ai/core/schema"
|
||||||
|
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||||
|
import { Schema, Struct } from "effect"
|
||||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||||
import {
|
import {
|
||||||
InvalidCursorError,
|
InvalidCursorError,
|
||||||
@@ -12,29 +15,73 @@ import {
|
|||||||
UnknownError,
|
UnknownError,
|
||||||
} from "../../errors"
|
} from "../../errors"
|
||||||
import { V2Authorization } from "../../middleware/authorization"
|
import { V2Authorization } from "../../middleware/authorization"
|
||||||
import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing"
|
import { WorkspaceRoutingQuery } from "../../middleware/workspace-routing"
|
||||||
import { QueryBoolean } from "../query"
|
|
||||||
|
|
||||||
export const SessionsQuery = Schema.Struct({
|
const SessionsQueryFields = {
|
||||||
...WorkspaceRoutingQueryFields,
|
workspace: WorkspaceV2.ID.pipe(Schema.optional),
|
||||||
limit: Schema.optional(
|
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({
|
||||||
Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)),
|
|
||||||
).annotate({
|
|
||||||
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
|
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
|
||||||
}),
|
}),
|
||||||
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
|
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
|
||||||
description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
|
description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
|
||||||
}),
|
}),
|
||||||
path: Schema.optional(Schema.String),
|
|
||||||
roots: Schema.optional(QueryBoolean),
|
|
||||||
start: Schema.optional(Schema.NumberFromString),
|
|
||||||
search: Schema.optional(Schema.String),
|
search: Schema.optional(Schema.String),
|
||||||
cursor: Schema.optional(
|
}
|
||||||
Schema.String.annotate({
|
|
||||||
description:
|
const SessionsDirectoryQuery = Schema.Struct({
|
||||||
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters.",
|
...SessionsQueryFields,
|
||||||
}),
|
directory: AbsolutePath,
|
||||||
),
|
})
|
||||||
|
|
||||||
|
const SessionsProjectQuery = Schema.Struct({
|
||||||
|
...SessionsQueryFields,
|
||||||
|
project: ProjectV2.ID,
|
||||||
|
subpath: RelativePath.pipe(Schema.optional),
|
||||||
|
})
|
||||||
|
|
||||||
|
const SessionsAllQuery = Schema.Struct(SessionsQueryFields)
|
||||||
|
|
||||||
|
const withCursor = <Fields extends Schema.Struct.Fields>(schema: Schema.Struct<Fields>) =>
|
||||||
|
schema.mapFields((fields) => ({
|
||||||
|
...Struct.omit(fields, ["limit"]),
|
||||||
|
anchor: SessionV2.ListAnchor,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const SessionsCursorInput = Schema.Union([
|
||||||
|
withCursor(SessionsDirectoryQuery),
|
||||||
|
withCursor(SessionsProjectQuery),
|
||||||
|
withCursor(SessionsAllQuery),
|
||||||
|
])
|
||||||
|
const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput)
|
||||||
|
const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson)
|
||||||
|
const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson)
|
||||||
|
|
||||||
|
export const SessionsCursor = Schema.String.pipe(
|
||||||
|
Schema.brand("V2SessionsCursor"),
|
||||||
|
withStatics((schema) => {
|
||||||
|
const make = schema.make
|
||||||
|
return {
|
||||||
|
make: (input: typeof SessionsCursorInput.Type) =>
|
||||||
|
make(Buffer.from(encodeSessionsCursor(input)).toString("base64url")),
|
||||||
|
parse: (input: string) => decodeSessionsCursor(Buffer.from(input, "base64url").toString("utf8")),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
export type SessionsCursor = typeof SessionsCursor.Type
|
||||||
|
|
||||||
|
const SessionsCursorQuery = Schema.Struct({
|
||||||
|
cursor: SessionsCursor.annotate({
|
||||||
|
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
|
||||||
|
}),
|
||||||
|
limit: SessionsQueryFields.limit,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SessionsQuery = Schema.Struct({
|
||||||
|
...SessionsQueryFields,
|
||||||
|
directory: AbsolutePath.pipe(Schema.optional),
|
||||||
|
project: ProjectV2.ID.pipe(Schema.optional),
|
||||||
|
subpath: RelativePath.pipe(Schema.optional),
|
||||||
|
cursor: SessionsCursorQuery.fields.cursor.pipe(Schema.optional),
|
||||||
}).annotate({ identifier: "V2SessionsQuery" })
|
}).annotate({ identifier: "V2SessionsQuery" })
|
||||||
|
|
||||||
export const SessionGroup = HttpApiGroup.make("v2.session")
|
export const SessionGroup = HttpApiGroup.make("v2.session")
|
||||||
@@ -44,8 +91,8 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
|
|||||||
success: Schema.Struct({
|
success: Schema.Struct({
|
||||||
items: Schema.Array(SessionV2.Info),
|
items: Schema.Array(SessionV2.Info),
|
||||||
cursor: Schema.Struct({
|
cursor: Schema.Struct({
|
||||||
previous: Schema.String.pipe(Schema.optional),
|
previous: SessionsCursor.pipe(Schema.optional),
|
||||||
next: Schema.String.pipe(Schema.optional),
|
next: SessionsCursor.pipe(Schema.optional),
|
||||||
}),
|
}),
|
||||||
}).annotate({ identifier: "V2SessionsResponse" }),
|
}).annotate({ identifier: "V2SessionsResponse" }),
|
||||||
error: [InvalidCursorError, InvalidRequestError],
|
error: [InvalidCursorError, InvalidRequestError],
|
||||||
|
|||||||
@@ -1,95 +1,12 @@
|
|||||||
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { DateTime, Effect } from "effect"
|
||||||
import { DateTime, Effect, Option, Schema } from "effect"
|
|
||||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||||
import { InstanceHttpApi } from "../../api"
|
import { InstanceHttpApi } from "../../api"
|
||||||
import {
|
import { SessionsCursor } from "../../groups/v2/session"
|
||||||
InvalidCursorError,
|
import { InvalidCursorError, ServiceUnavailableError, SessionNotFoundError, UnknownError } from "../../errors"
|
||||||
InvalidRequestError,
|
|
||||||
ServiceUnavailableError,
|
|
||||||
SessionNotFoundError,
|
|
||||||
UnknownError,
|
|
||||||
} from "../../errors"
|
|
||||||
|
|
||||||
const DefaultSessionsLimit = 50
|
const DefaultSessionsLimit = 50
|
||||||
|
|
||||||
const SessionCursor = Schema.Struct({
|
|
||||||
id: SessionV2.Info.fields.id,
|
|
||||||
time: Schema.Finite,
|
|
||||||
order: Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")]),
|
|
||||||
direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]),
|
|
||||||
directory: Schema.String.pipe(Schema.optional),
|
|
||||||
path: Schema.String.pipe(Schema.optional),
|
|
||||||
workspaceID: WorkspaceV2.ID.pipe(Schema.optional),
|
|
||||||
roots: Schema.Boolean.pipe(Schema.optional),
|
|
||||||
start: Schema.Finite.pipe(Schema.optional),
|
|
||||||
search: Schema.String.pipe(Schema.optional),
|
|
||||||
})
|
|
||||||
type SessionCursor = typeof SessionCursor.Type
|
|
||||||
|
|
||||||
const decodeCursor = Schema.decodeUnknownSync(SessionCursor)
|
|
||||||
|
|
||||||
function hasCursorFilter(query: {
|
|
||||||
readonly order?: unknown
|
|
||||||
readonly path?: unknown
|
|
||||||
readonly roots?: unknown
|
|
||||||
readonly start?: unknown
|
|
||||||
readonly search?: unknown
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
query.order !== undefined ||
|
|
||||||
query.path !== undefined ||
|
|
||||||
query.roots !== undefined ||
|
|
||||||
query.start !== undefined ||
|
|
||||||
query.search !== undefined
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasCursorRoutingMismatch(
|
|
||||||
query: { readonly directory?: string; readonly workspace?: string },
|
|
||||||
decoded: SessionCursor | undefined,
|
|
||||||
) {
|
|
||||||
if (!decoded) return false
|
|
||||||
if (query.directory !== undefined && query.directory !== decoded.directory) return true
|
|
||||||
return query.workspace !== undefined && query.workspace !== decoded.workspaceID
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionCursor = {
|
|
||||||
encode(
|
|
||||||
session: SessionV2.Info,
|
|
||||||
order: "asc" | "desc",
|
|
||||||
direction: "previous" | "next",
|
|
||||||
filters: Pick<SessionCursor, "directory" | "path" | "workspaceID" | "roots" | "start" | "search">,
|
|
||||||
) {
|
|
||||||
return Buffer.from(
|
|
||||||
JSON.stringify({
|
|
||||||
...filters,
|
|
||||||
id: session.id,
|
|
||||||
time: DateTime.toEpochMillis(session.time.updated),
|
|
||||||
order,
|
|
||||||
direction,
|
|
||||||
}),
|
|
||||||
).toString("base64url")
|
|
||||||
},
|
|
||||||
decode(input: string) {
|
|
||||||
return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8")))
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeWorkspaceID(input: string | undefined) {
|
|
||||||
if (input === undefined) return Effect.succeed(undefined)
|
|
||||||
const workspaceID = Schema.decodeUnknownOption(WorkspaceV2.ID)(input)
|
|
||||||
if (Option.isSome(workspaceID)) return Effect.succeed(workspaceID.value)
|
|
||||||
return Effect.fail(
|
|
||||||
new InvalidRequestError({
|
|
||||||
message: "Invalid workspace query parameter",
|
|
||||||
kind: "Query",
|
|
||||||
field: "workspace",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session", (handlers) =>
|
export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session", (handlers) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* SessionV2.Service
|
const session = yield* SessionV2.Service
|
||||||
@@ -98,45 +15,42 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
|
|||||||
.handle(
|
.handle(
|
||||||
"sessions",
|
"sessions",
|
||||||
Effect.fn(function* (ctx) {
|
Effect.fn(function* (ctx) {
|
||||||
if (ctx.query.cursor && hasCursorFilter(ctx.query))
|
const query =
|
||||||
return yield* new InvalidCursorError({ message: "Cursor cannot be combined with order or filters" })
|
ctx.query.cursor !== undefined
|
||||||
const decoded = yield* Effect.try({
|
? yield* SessionsCursor.parse(ctx.query.cursor).pipe(
|
||||||
try: () => (ctx.query.cursor ? sessionCursor.decode(ctx.query.cursor) : undefined),
|
Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })),
|
||||||
catch: () => new InvalidCursorError({ message: "Invalid cursor" }),
|
)
|
||||||
})
|
: ctx.query
|
||||||
if (hasCursorRoutingMismatch(ctx.query, decoded))
|
const sessions = yield* session.list({
|
||||||
return yield* new InvalidCursorError({ message: "Cursor does not match requested directory or workspace" })
|
...query,
|
||||||
const order = decoded?.order ?? ctx.query.order ?? "desc"
|
workspaceID: query.workspace,
|
||||||
const filters = decoded ?? {
|
|
||||||
directory: ctx.query.directory,
|
|
||||||
path: ctx.query.path,
|
|
||||||
workspaceID: yield* decodeWorkspaceID(ctx.query.workspace),
|
|
||||||
roots: ctx.query.roots,
|
|
||||||
start: ctx.query.start,
|
|
||||||
search: ctx.query.search,
|
|
||||||
}
|
|
||||||
const input = {
|
|
||||||
limit: ctx.query.limit ?? DefaultSessionsLimit,
|
limit: ctx.query.limit ?? DefaultSessionsLimit,
|
||||||
order,
|
})
|
||||||
workspaceID: filters.workspaceID,
|
|
||||||
search: filters.search,
|
|
||||||
cursor: decoded ? { id: decoded.id, time: decoded.time, direction: decoded.direction } : undefined,
|
|
||||||
}
|
|
||||||
const sessions = yield* session.list(
|
|
||||||
filters.directory
|
|
||||||
? {
|
|
||||||
...input,
|
|
||||||
directory: AbsolutePath.make(filters.directory),
|
|
||||||
}
|
|
||||||
: input,
|
|
||||||
)
|
|
||||||
const first = sessions[0]
|
const first = sessions[0]
|
||||||
const last = sessions.at(-1)
|
const last = sessions.at(-1)
|
||||||
return {
|
return {
|
||||||
items: sessions,
|
items: sessions,
|
||||||
cursor: {
|
cursor: {
|
||||||
previous: first ? sessionCursor.encode(first, order, "previous", filters) : undefined,
|
previous: first
|
||||||
next: last ? sessionCursor.encode(last, order, "next", filters) : undefined,
|
? SessionsCursor.make({
|
||||||
|
...query,
|
||||||
|
anchor: {
|
||||||
|
id: first.id,
|
||||||
|
time: DateTime.toEpochMillis(first.time.created),
|
||||||
|
direction: "previous",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
|
next: last
|
||||||
|
? SessionsCursor.make({
|
||||||
|
...query,
|
||||||
|
anchor: {
|
||||||
|
id: last.id,
|
||||||
|
time: DateTime.toEpochMillis(last.time.created),
|
||||||
|
direction: "next",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
} from "../../src/server/routes/instance/httpapi/groups/session"
|
} from "../../src/server/routes/instance/httpapi/groups/session"
|
||||||
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
|
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
|
||||||
import { MessagesQuery as V2MessagesQuery } from "../../src/server/routes/instance/httpapi/groups/v2/message"
|
import { MessagesQuery as V2MessagesQuery } from "../../src/server/routes/instance/httpapi/groups/v2/message"
|
||||||
import { SessionsQuery as V2SessionsQuery } from "../../src/server/routes/instance/httpapi/groups/v2/session"
|
|
||||||
import { QueryBoolean, QueryBooleanOpenApi } from "../../src/server/routes/instance/httpapi/groups/query"
|
import { QueryBoolean, QueryBooleanOpenApi } from "../../src/server/routes/instance/httpapi/groups/query"
|
||||||
import { resetDatabase } from "../fixture/db"
|
import { resetDatabase } from "../fixture/db"
|
||||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||||
@@ -55,7 +54,6 @@ const openApiDriftRoutes = [
|
|||||||
{ method: "get", path: ExperimentalPaths.session, query: ExperimentalSessionListQuery },
|
{ method: "get", path: ExperimentalPaths.session, query: ExperimentalSessionListQuery },
|
||||||
{ method: "get", path: ExperimentalPaths.tool, query: ToolListQuery },
|
{ method: "get", path: ExperimentalPaths.tool, query: ToolListQuery },
|
||||||
{ method: "get", path: InstancePaths.vcsDiff, query: VcsDiffQuery },
|
{ method: "get", path: InstancePaths.vcsDiff, query: VcsDiffQuery },
|
||||||
{ method: "get", path: "/api/session", query: V2SessionsQuery },
|
|
||||||
{ method: "get", path: "/api/session/:sessionID/message", query: V2MessagesQuery },
|
{ method: "get", path: "/api/session/:sessionID/message", query: V2MessagesQuery },
|
||||||
] satisfies Array<{ method: Method; path: string; query: QuerySchema }>
|
] satisfies Array<{ method: Method; path: string; query: QuerySchema }>
|
||||||
|
|
||||||
@@ -72,8 +70,6 @@ const numericSdkQueryParams = [
|
|||||||
name: "limit",
|
name: "limit",
|
||||||
schema: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
|
schema: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
|
||||||
},
|
},
|
||||||
{ method: "get", path: "/api/session", name: "limit", schema: { type: "number" } },
|
|
||||||
{ method: "get", path: "/api/session", name: "start", schema: { type: "number" } },
|
|
||||||
{ method: "get", path: "/api/session/:sessionID/message", name: "limit", schema: { type: "number" } },
|
{ method: "get", path: "/api/session/:sessionID/message", name: "limit", schema: { type: "number" } },
|
||||||
] satisfies Array<{ method: Method; path: string; name: string; schema: OpenApiSchema }>
|
] satisfies Array<{ method: Method; path: string; name: string; schema: OpenApiSchema }>
|
||||||
|
|
||||||
@@ -81,7 +77,6 @@ const booleanSdkQueryParams = [
|
|||||||
{ method: "get", path: ExperimentalPaths.session, name: "roots" },
|
{ method: "get", path: ExperimentalPaths.session, name: "roots" },
|
||||||
{ method: "get", path: ExperimentalPaths.session, name: "archived" },
|
{ method: "get", path: ExperimentalPaths.session, name: "archived" },
|
||||||
{ method: "get", path: SessionPaths.list, name: "roots" },
|
{ method: "get", path: SessionPaths.list, name: "roots" },
|
||||||
{ method: "get", path: "/api/session", name: "roots" },
|
|
||||||
] satisfies Array<{ method: Method; path: string; name: string }>
|
] satisfies Array<{ method: Method; path: string; name: string }>
|
||||||
|
|
||||||
const queryParamPatterns = [
|
const queryParamPatterns = [
|
||||||
@@ -239,7 +234,7 @@ describe("httpapi query schema drift", () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
path: "/fixture",
|
path: "/fixture",
|
||||||
query: { fields: {} },
|
query: Schema.Struct({}),
|
||||||
}),
|
}),
|
||||||
).toThrow("advertises query params not accepted by runtime schema")
|
).toThrow("advertises query params not accepted by runtime schema")
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -444,17 +444,27 @@ describe("session HttpApi", () => {
|
|||||||
yield* insertLegacyAssistantMessage(session.id, 1)
|
yield* insertLegacyAssistantMessage(session.id, 1)
|
||||||
yield* insertLegacyAssistantMessage(session.id, 2)
|
yield* insertLegacyAssistantMessage(session.id, 2)
|
||||||
|
|
||||||
const sessionPage = yield* request(`/api/session?limit=1`, { headers })
|
const sessionPage = yield* request(
|
||||||
|
`/api/session?${new URLSearchParams({
|
||||||
|
limit: "1",
|
||||||
|
order: "asc",
|
||||||
|
directory: test.directory,
|
||||||
|
search: "v2",
|
||||||
|
})}`,
|
||||||
|
{ headers },
|
||||||
|
)
|
||||||
const sessionCursor = (yield* json<{ cursor: { next?: string } }>(sessionPage)).cursor.next
|
const sessionCursor = (yield* json<{ cursor: { next?: string } }>(sessionPage)).cursor.next
|
||||||
expect(sessionCursor).toBeTruthy()
|
expect(sessionCursor).toBeTruthy()
|
||||||
|
expect(JSON.parse(Buffer.from(sessionCursor!, "base64url").toString("utf8"))).toMatchObject({
|
||||||
const cursorWithFilter = yield* request(`/api/session?cursor=${sessionCursor}&search=v2`, { headers })
|
order: "asc",
|
||||||
expect(cursorWithFilter.status).toBe(400)
|
directory: test.directory,
|
||||||
expect(yield* responseJson(cursorWithFilter)).toMatchObject({
|
search: "v2",
|
||||||
_tag: "InvalidCursorError",
|
anchor: { id: session.id, direction: "next" },
|
||||||
message: "Cursor cannot be combined with order or filters",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const sessionNextPage = yield* request(`/api/session?cursor=${sessionCursor}`, { headers })
|
||||||
|
expect(sessionNextPage.status).toBe(200)
|
||||||
|
|
||||||
const invalidSessionCursor = yield* request(`/api/session?cursor=invalid`, { headers })
|
const invalidSessionCursor = yield* request(`/api/session?cursor=invalid`, { headers })
|
||||||
expect(invalidSessionCursor.status).toBe(400)
|
expect(invalidSessionCursor.status).toBe(400)
|
||||||
expect(yield* responseJson(invalidSessionCursor)).toMatchObject({
|
expect(yield* responseJson(invalidSessionCursor)).toMatchObject({
|
||||||
@@ -462,21 +472,11 @@ describe("session HttpApi", () => {
|
|||||||
message: "Invalid cursor",
|
message: "Invalid cursor",
|
||||||
})
|
})
|
||||||
|
|
||||||
const mismatchedRouting = yield* request(`/api/session?cursor=${sessionCursor}&directory=/elsewhere`, {
|
|
||||||
headers,
|
|
||||||
})
|
|
||||||
expect(mismatchedRouting.status).toBe(400)
|
|
||||||
expect(yield* responseJson(mismatchedRouting)).toMatchObject({
|
|
||||||
_tag: "InvalidCursorError",
|
|
||||||
message: "Cursor does not match requested directory or workspace",
|
|
||||||
})
|
|
||||||
|
|
||||||
const invalidWorkspace = yield* request(`/api/session?workspace=bad`, { headers })
|
const invalidWorkspace = yield* request(`/api/session?workspace=bad`, { headers })
|
||||||
expect(invalidWorkspace.status).toBe(400)
|
expect(invalidWorkspace.status).toBe(400)
|
||||||
expect(yield* responseJson(invalidWorkspace)).toMatchObject({
|
expect(yield* responseJson(invalidWorkspace)).toMatchObject({
|
||||||
_tag: "InvalidRequestError",
|
_tag: "InvalidRequestError",
|
||||||
message: "Invalid workspace query parameter",
|
kind: "Query",
|
||||||
field: "workspace",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const messagePage = yield* request(`/api/session/${session.id}/message?limit=1`, { headers })
|
const messagePage = yield* request(`/api/session/${session.id}/message?limit=1`, { headers })
|
||||||
|
|||||||
@@ -4342,14 +4342,13 @@ export class Session3 extends HeyApiClient {
|
|||||||
*/
|
*/
|
||||||
public list<ThrowOnError extends boolean = false>(
|
public list<ThrowOnError extends boolean = false>(
|
||||||
parameters?: {
|
parameters?: {
|
||||||
directory?: string
|
|
||||||
workspace?: string
|
workspace?: string
|
||||||
limit?: number
|
limit?: number
|
||||||
order?: "asc" | "desc"
|
order?: "asc" | "desc"
|
||||||
path?: string
|
|
||||||
roots?: boolean | "true" | "false"
|
|
||||||
start?: number
|
|
||||||
search?: string
|
search?: string
|
||||||
|
directory?: string
|
||||||
|
project?: string
|
||||||
|
subpath?: string
|
||||||
cursor?: string
|
cursor?: string
|
||||||
},
|
},
|
||||||
options?: Options<never, ThrowOnError>,
|
options?: Options<never, ThrowOnError>,
|
||||||
@@ -4359,14 +4358,13 @@ export class Session3 extends HeyApiClient {
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
args: [
|
args: [
|
||||||
{ in: "query", key: "directory" },
|
|
||||||
{ in: "query", key: "workspace" },
|
{ in: "query", key: "workspace" },
|
||||||
{ in: "query", key: "limit" },
|
{ in: "query", key: "limit" },
|
||||||
{ in: "query", key: "order" },
|
{ in: "query", key: "order" },
|
||||||
{ in: "query", key: "path" },
|
|
||||||
{ in: "query", key: "roots" },
|
|
||||||
{ in: "query", key: "start" },
|
|
||||||
{ in: "query", key: "search" },
|
{ in: "query", key: "search" },
|
||||||
|
{ in: "query", key: "directory" },
|
||||||
|
{ in: "query", key: "project" },
|
||||||
|
{ in: "query", key: "subpath" },
|
||||||
{ in: "query", key: "cursor" },
|
{ in: "query", key: "cursor" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7969,16 +7969,15 @@ export type V2SessionListData = {
|
|||||||
body?: never
|
body?: never
|
||||||
path?: never
|
path?: never
|
||||||
query?: {
|
query?: {
|
||||||
directory?: string
|
|
||||||
workspace?: string
|
workspace?: string
|
||||||
limit?: number
|
limit?: number
|
||||||
order?: "asc" | "desc"
|
order?: "asc" | "desc"
|
||||||
path?: string
|
|
||||||
roots?: boolean | "true" | "false"
|
|
||||||
start?: number
|
|
||||||
search?: string
|
search?: string
|
||||||
|
directory?: string
|
||||||
|
project?: string
|
||||||
|
subpath?: string
|
||||||
/**
|
/**
|
||||||
* Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters.
|
* Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.
|
||||||
*/
|
*/
|
||||||
cursor?: string
|
cursor?: string
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user