-
Notifications
You must be signed in to change notification settings - Fork 46
feat(web): list archived sessions #391
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,10 @@ import { | |
| projectEntries, | ||
| projectEntry, | ||
| WEB_MAX_MODELS, | ||
| WEB_MAX_ARCHIVED_SESSION_PAGE, | ||
| WEB_MAX_ARCHIVED_SESSION_CURSOR, | ||
| WEB_MAX_ARCHIVED_SESSION_QUERY, | ||
| WEB_MAX_ARCHIVED_SESSION_SCAN, | ||
| WEB_MAX_SESSIONS, | ||
| WEB_MAX_SESSION_PREVIEW, | ||
| WEB_MAX_SNAPSHOT_BYTES, | ||
|
|
@@ -27,6 +31,47 @@ type WorkspaceStateSnapshot = { | |
| restoreInitialWorkspace: boolean; | ||
| }; | ||
|
|
||
| export interface ArchivedSessionQuery { | ||
| readonly cursor?: string; | ||
| readonly limit?: number; | ||
| readonly query?: string; | ||
| } | ||
|
|
||
| interface ArchivedSessionCursor { | ||
| readonly version: 1; | ||
| readonly sessionId: string; | ||
| readonly query: string; | ||
| } | ||
|
|
||
| function encodeArchivedSessionCursor(cursor: ArchivedSessionCursor) { | ||
| return Buffer.from(JSON.stringify(cursor)).toString("base64url"); | ||
| } | ||
|
|
||
| function decodeArchivedSessionCursor(value: string) { | ||
| try { | ||
| const bytes = Buffer.from(value, "base64url"); | ||
| if (bytes.toString("base64url") !== value) return undefined; | ||
| const parsed: unknown = JSON.parse(bytes.toString("utf8")); | ||
| if ( | ||
| !parsed || | ||
| typeof parsed !== "object" || | ||
| Array.isArray(parsed) || | ||
| (parsed as { version?: unknown }).version !== 1 || | ||
| typeof (parsed as { sessionId?: unknown }).sessionId !== "string" || | ||
| (parsed as { sessionId: string }).sessionId.length === 0 || | ||
| (parsed as { sessionId: string }).sessionId.length > 160 || | ||
| typeof (parsed as { query?: unknown }).query !== "string" || | ||
| (parsed as { query: string }).query.length > | ||
| WEB_MAX_ARCHIVED_SESSION_QUERY | ||
| ) { | ||
| return undefined; | ||
| } | ||
| return parsed as ArchivedSessionCursor; | ||
| } catch { | ||
| return undefined; | ||
| } | ||
| } | ||
|
|
||
| export class PiWebAdapter { | ||
| private readonly runtime: WebRuntimeController; | ||
| private readonly importedWorkspaces = new Set<string>(); | ||
|
|
@@ -284,6 +329,102 @@ export class PiWebAdapter { | |
| }); | ||
| } | ||
|
|
||
| async listArchivedSessions(options: ArchivedSessionQuery = {}) { | ||
| await this.ensureWorkspaceStateLoaded(); | ||
| await this.ensureArchivesLoaded(); | ||
| const limit = options.limit ?? 25; | ||
| const query = options.query?.trim() ?? ""; | ||
| if ( | ||
| !Number.isSafeInteger(limit) || | ||
| limit <= 0 || | ||
| limit > WEB_MAX_ARCHIVED_SESSION_PAGE || | ||
| query.length > WEB_MAX_ARCHIVED_SESSION_QUERY || | ||
| /[\u0000-\u001f\u007f]/u.test(query) || | ||
| (options.cursor !== undefined && | ||
| (options.cursor.length === 0 || | ||
| options.cursor.length > WEB_MAX_ARCHIVED_SESSION_CURSOR || | ||
| /[\u0000-\u001f\u007f]/u.test(options.cursor))) | ||
| ) { | ||
| return { status: "invalid" as const }; | ||
| } | ||
|
|
||
| const allSessions = await SessionManager.listAll( | ||
| this.runtime.sessionDirectory, | ||
| ); | ||
| const scanned = allSessions.slice(0, WEB_MAX_ARCHIVED_SESSION_SCAN); | ||
| const normalizedQuery = query.normalize("NFKC").toLocaleLowerCase(); | ||
| const matches = scanned.filter((session) => { | ||
| if (!this.archivedSessions.has(resolve(session.path))) return false; | ||
| if (!normalizedQuery) return true; | ||
| const source = [ | ||
| session.id, | ||
| session.name ?? "", | ||
| session.cwd, | ||
| session.firstMessage.slice(0, 2_000), | ||
| ] | ||
| .join("\n") | ||
| .normalize("NFKC") | ||
| .toLocaleLowerCase(); | ||
| return source.includes(normalizedQuery); | ||
| }); | ||
| let start = 0; | ||
| if (options.cursor !== undefined) { | ||
| const cursor = decodeArchivedSessionCursor(options.cursor); | ||
| if (!cursor) return { status: "invalid" as const }; | ||
| if (cursor.query !== normalizedQuery) { | ||
| return { status: "stale_cursor" as const }; | ||
| } | ||
| const cursorIndex = matches.findIndex( | ||
| (session) => session.id === cursor.sessionId, | ||
| ); | ||
| if (cursorIndex < 0) return { status: "stale_cursor" as const }; | ||
| start = cursorIndex + 1; | ||
| } | ||
| const selected = matches.slice(start, start + limit); | ||
| const sessions = selected.map((session) => ({ | ||
| id: session.id, | ||
| path: session.path, | ||
| cwd: resolve(session.cwd), | ||
| ...(session.name | ||
| ? { name: boundedText(session.name, WEB_MAX_SESSION_PREVIEW) } | ||
| : {}), | ||
| modified: session.modified.toISOString(), | ||
| created: session.created.toISOString(), | ||
| messageCount: session.messageCount, | ||
| firstMessage: boundedText( | ||
| session.firstMessage, | ||
| WEB_MAX_SESSION_PREVIEW, | ||
| ), | ||
| archived: true, | ||
| ...(this.ungroupedSessions.has(resolve(session.path)) | ||
| ? { ungrouped: true } | ||
| : {}), | ||
| })); | ||
| const pageEnd = start + sessions.length; | ||
| const hasMoreMatches = pageEnd < matches.length; | ||
| const recordsUnscanned = Math.max(0, allSessions.length - scanned.length); | ||
| return { | ||
| status: "ok" as const, | ||
| sessions, | ||
| ...(hasMoreMatches && sessions.length > 0 | ||
| ? { | ||
| nextCursor: encodeArchivedSessionCursor({ | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Ensure every generated cursor is valid for the next request Valid queries can produce cursors that the same endpoint rejects. Real-file reproduction: two matching archived Sessions, query="中".repeat(160), limit=1 gives status ok and a 740-character nextCursor, but replaying it returns invalid/HTTP400 because WEB_MAX_ARCHIVED_SESSION_CURSOR is512. Separately query="ffi".repeat(60) has raw length60 but NFKC length180; the generated cursor is only296 characters, yet decoding rejects its normalized query against the160-character raw-query cap. Keep input normalization, encoding and decoder limits consistent, or use a bounded query fingerprint, and test replay of the returned cursor for Chinese and NFKC-expanding queries. |
||
| version: 1, | ||
| sessionId: sessions.at(-1)!.id, | ||
| query: normalizedQuery, | ||
| }), | ||
| } | ||
| : {}), | ||
| truncation: { | ||
| truncated: hasMoreMatches || recordsUnscanned > 0, | ||
| matchesOmitted: Math.max(0, matches.length - pageEnd), | ||
| recordsUnscanned, | ||
| maxPageSize: WEB_MAX_ARCHIVED_SESSION_PAGE, | ||
| maxScanned: WEB_MAX_ARCHIVED_SESSION_SCAN, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| async removeWorkspace(path: string) { | ||
| await this.ensureWorkspaceStateLoaded(); | ||
| const canonical = resolve(path); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P2] Bind the cursor to a unique Session file identity
Using only session.id cannot distinguish copied Session files that retain their original ID. With three archived files ordered newest/middle/oldest, where the first two share an ID and limit=1, actual requests return newest -> middle -> middle -> middle indefinitely: findIndex keeps resolving the first matching ID, and the oldest entry is unreachable. Bind the cursor to the file identity (and validate it against the same result set) rather than ID alone. Add a real-file duplicate-ID pagination regression proving each file is visited once and pagination terminates.