diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts index c3d605d424..580e8d4abc 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts @@ -45,32 +45,30 @@ test('restarts a paginated catalog read instead of mixing revisions', async () = ]); assert.deepEqual( - (await client.listSessions({ isArchived: false })).map(({ id }) => id), + (await client.listSessions()).map(({ id }) => id), ['fresh-1', 'fresh-2'], ); assert.deepEqual(requests, [ { operation: 'session.catalog.query', - input: { kind: 'list_start', filter: { isArchived: false } }, + input: { kind: 'list_start' }, }, { operation: 'session.catalog.query', input: { kind: 'list_continue', - filter: { isArchived: false }, revision: revisionOne, cursor: 'stale-cursor', }, }, { operation: 'session.catalog.query', - input: { kind: 'list_start', filter: { isArchived: false } }, + input: { kind: 'list_start' }, }, { operation: 'session.catalog.query', input: { kind: 'list_continue', - filter: { isArchived: false }, revision: revisionTwo, cursor: 'fresh-cursor', }, diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts index 03b4fd06a2..d6a915f80e 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts @@ -254,6 +254,16 @@ test('drives the renderer Session catalog facade through real UDS framing', asyn const created = await ipc.invoke('sessions:create', undefined); assert.deepEqual((await ipc.invoke('sessions:list')) as unknown[], [created]); + for (const staleFilter of [ + { isArchived: false }, + { isFlagged: true }, + { labelSlug: 'paged' }, + ]) { + await assert.rejects( + ipc.invoke('sessions:list', staleFilter), + /Invalid Session list filter/, + ); + } assert.equal( (await ipc.invoke('sessions:setPermissionMode', 'session-ipc', 'execute') as { permissionMode: string; diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 0a3a705c9b..f5d735316a 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -71,7 +71,6 @@ import { type ProjectDirectoryRoot, type QueueRetractInput, type QueueRetractResult, - type SessionCatalogFilter, SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, type SessionCatalogChangedFrame, type ScheduledTaskChangedFrame, @@ -529,12 +528,10 @@ export class DesktopRuntimeHostClient { } } - async listSessions( - filter?: SessionCatalogFilter, - ): Promise { + async listSessions(): Promise { this.#assertOpen(); try { - return (await readRuntimeHostSessions(this.connection, filter)).map(requireSessionProjection); + return (await readRuntimeHostSessions(this.connection)).map(requireSessionProjection); } catch (error) { if (error instanceof DesktopRuntimeHostClientError) throw error; if (!(error instanceof RuntimeHostCatalogReadError)) throw error; diff --git a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts index 1140123360..2e8fc26c88 100644 --- a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts @@ -6,7 +6,6 @@ import { isThinkingLevel } from '@maka/core/model-thinking'; import { type CreateSessionRequestInput, type SessionListFilter } from '@maka/core/runtime-inputs'; import { type SessionChangedEvent, type SessionChangedReason, type SessionSummary } from '@maka/core/session'; import type { - SessionCatalogFilter, SessionCatalogProjection, SessionCreateInput, WorkspaceTarget, @@ -70,7 +69,7 @@ export function registerRuntimeHostSessionCatalogIpc( const listSessions = async (filter?: SessionListFilter): Promise => { await recoveryTask; const parentSessionId = normalizeParentSessionFilter(filter?.subagentParentSessionId); - const sessions = await deps.client.listSessions(toHostCatalogFilter(filter)); + const sessions = await deps.client.listSessions(); return sessions .filter((session) => !pendingCleanup.has(session.id)) .filter((session) => @@ -81,8 +80,8 @@ export function registerRuntimeHostSessionCatalogIpc( const actionIds = (sessionId: string, options: unknown) => resolveSessionActionIds(() => listSessions(), sessionId, options); - handleReconnectableRead(ipcMain, 'sessions:list', (_event, filter?: SessionListFilter) => - listSessions(filter), + handleReconnectableRead(ipcMain, 'sessions:list', (_event, filter?: unknown) => + listSessions(normalizeSessionListFilter(filter)), ); ipcMain.handle('sessions:cleanupSessionCopy', async (_event, sessionId: string) => { await deps.sessionCopyCleanup.cleanup(sessionId); @@ -245,16 +244,6 @@ async function updateConfiguration( return toDesktopHostSessionSummary(session); } -function toHostCatalogFilter(filter: SessionListFilter | undefined): SessionCatalogFilter | undefined { - if (!filter) return undefined; - const result: SessionCatalogFilter = { - ...(filter.isArchived === undefined ? {} : { isArchived: filter.isArchived }), - ...(filter.isFlagged === undefined ? {} : { isFlagged: filter.isFlagged }), - ...(filter.labelSlug === undefined ? {} : { labelSlug: filter.labelSlug }), - }; - return Object.keys(result).length === 0 ? undefined : result; -} - function normalizeParentSessionFilter(value: unknown): string | undefined { if (value === undefined) return undefined; if (typeof value !== 'string' || value.length === 0) { @@ -263,6 +252,26 @@ function normalizeParentSessionFilter(value: unknown): string | undefined { return value; } +function normalizeSessionListFilter(value: unknown): SessionListFilter | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error('Invalid Session list filter'); + } + const record = value as Record; + if (Object.keys(record).some((key) => key !== 'subagentParentSessionId')) { + throw new Error('Invalid Session list filter keys'); + } + return { + ...(record.subagentParentSessionId === undefined + ? {} + : { + subagentParentSessionId: normalizeParentSessionFilter( + record.subagentParentSessionId, + ), + }), + }; +} + function normalizeModelTarget(input: CreateSessionRequestInput | undefined): SessionModelTarget { const slug = normalizeOptionalString(input?.llmConnectionSlug, 'model connection'); const model = normalizeOptionalString(input?.model, 'model'); diff --git a/packages/core/src/runtime-inputs.ts b/packages/core/src/runtime-inputs.ts index ece4766e9f..6104b785bf 100644 --- a/packages/core/src/runtime-inputs.ts +++ b/packages/core/src/runtime-inputs.ts @@ -138,9 +138,6 @@ export interface ReviseBeforeTurnInput { } export interface SessionListFilter { - isArchived?: boolean; - isFlagged?: boolean; - labelSlug?: string; /** Return linked subagent sessions owned by this parent session. */ subagentParentSessionId?: string; } diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index fa3ff2c24d..70122fc8df 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -862,6 +862,32 @@ test('catalog paging stops before the encoded 48 KiB result boundary', async () ); }); +test('rejects a legacy cursor that carries a Session catalog filter', async () => { + const fixture = createFixture(); + const cursor = Buffer.from( + JSON.stringify({ + version: 1, + activityAt: 1, + sessionId: 'session-1', + filter: { isArchived: false }, + }), + 'utf8', + ).toString('base64url'); + + const outcome = await fixture.coordinator.handlers['session.catalog.query']( + { + kind: 'list_continue', + revision: 'sha256:test', + cursor, + }, + context, + ); + + assert.equal(outcome.ok, false); + if (outcome.ok) assert.fail('Legacy filtered cursor must be rejected'); + assert.equal(outcome.error.code, 'invalid_request'); +}); + function createFixture( options: { readonly labels?: readonly string[]; diff --git a/packages/runtime-host/src/__tests__/session-catalog-protocol.test.ts b/packages/runtime-host/src/__tests__/session-catalog-protocol.test.ts index 41e20f7091..dd4a32f5b9 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-protocol.test.ts @@ -234,6 +234,54 @@ describe('Session catalog protocol', () => { ); }); + test('accepts only filter-free Session catalog list inputs', () => { + const revision = `sha256:${'a'.repeat(64)}` as const; + assert.deepEqual( + decodeClientFrame({ + requestId: 'request-list', + operation: 'session.catalog.query', + input: { kind: 'list_start' }, + }), + { + requestId: 'request-list', + operation: 'session.catalog.query', + input: { kind: 'list_start' }, + }, + ); + assert.deepEqual( + decodeClientFrame({ + requestId: 'request-continue', + operation: 'session.catalog.query', + input: { kind: 'list_continue', revision, cursor: 'cursor-1' }, + }), + { + requestId: 'request-continue', + operation: 'session.catalog.query', + input: { kind: 'list_continue', revision, cursor: 'cursor-1' }, + }, + ); + for (const filter of [{ isArchived: false }, { isFlagged: true }, { labelSlug: 'paged' }]) { + assert.throws( + () => + decodeClientFrame({ + requestId: 'request-reject', + operation: 'session.catalog.query', + input: { kind: 'list_start', filter }, + }), + isProtocolError, + ); + assert.throws( + () => + decodeClientFrame({ + requestId: 'request-reject', + operation: 'session.catalog.query', + input: { kind: 'list_continue', revision, cursor: 'cursor-1', filter }, + }), + isProtocolError, + ); + } + }); + test('accepts the complete 80-code-point Session name range', () => { const name = '🦊'.repeat(80); const decoded = decodeClientFrame({ diff --git a/packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts index cee55d7f1b..db4474949e 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts @@ -434,41 +434,6 @@ test('two Clients share stable Session creation, CAS configuration, and catalog bulk.length + catalogBeforeBulk.sessions.length, ); - const filteredStart = await desktop.request('session.catalog.query', { - kind: 'list_start', - filter: { labelSlug: 'paged' }, - }); - assert.equal(filteredStart.kind, 'page'); - if (filteredStart.kind !== 'page' || !filteredStart.nextCursor) { - assert.fail('Filtered Session catalog must provide a continuation'); - } - assert.equal(filteredStart.sessions.length, 32); - const filteredContinuation = await tui.request('session.catalog.query', { - kind: 'list_continue', - revision: filteredStart.revision, - cursor: filteredStart.nextCursor, - }); - assert.equal(filteredContinuation.kind, 'page'); - if (filteredContinuation.kind !== 'page') { - assert.fail('Filtered Session catalog continuation must return a page'); - } - assert.equal(filteredContinuation.sessions.length, 2); - assert.equal( - [...filteredStart.sessions, ...filteredContinuation.sessions].every((session) => - requireSessionProjection(session).labels.includes('paged'), - ), - true, - ); - await assert.rejects( - desktop.request('session.catalog.query', { - kind: 'list_continue', - filter: { isFlagged: true }, - revision: filteredStart.revision, - cursor: filteredStart.nextCursor, - }), - operationError('invalid_request'), - ); - const staleStart = await desktop.request('session.catalog.query', { kind: 'list_start', }); @@ -489,16 +454,6 @@ test('two Clients share stable Session creation, CAS configuration, and catalog cursor: staleStart.nextCursor, }); assert.equal(staleContinuation.kind, 'revision_changed'); - const flagged = await tui.request('session.catalog.query', { - kind: 'list_start', - filter: { isFlagged: true }, - }); - assert.equal(flagged.kind, 'page'); - if (flagged.kind !== 'page') assert.fail('Flagged Session query must return a page'); - assert.deepEqual( - flagged.sessions.map((session) => session.id).sort(), - [created.id, bulkSession.id, oversizedSessionId].sort(), - ); await subscription.close(); const retirementSubscription = await tui.openSessionSubscription({ diff --git a/packages/runtime-host/src/client/catalog-reader.ts b/packages/runtime-host/src/client/catalog-reader.ts index a59e0c6baf..ebbcaa6f63 100644 --- a/packages/runtime-host/src/client/catalog-reader.ts +++ b/packages/runtime-host/src/client/catalog-reader.ts @@ -6,7 +6,6 @@ import { type ConnectionCatalogQueryResult, type RelayModelProfile, type RelayModelProfiles, - type SessionCatalogFilter, type SessionCatalogItem, type SkillCatalogWorkspaceContext, type SkillCatalogInvocableItem, @@ -165,14 +164,12 @@ export async function readRuntimeHostInvocableSkills( export async function readRuntimeHostSessions( connection: RuntimeHostCatalogConnection, - filter?: SessionCatalogFilter, ): Promise { const { pages } = await collectStablePages( 'session', async () => { const result = await connection.request('session.catalog.query', { kind: 'list_start', - ...(filter ? { filter } : {}), }); return result.kind === 'page' ? result : null; }, @@ -181,7 +178,6 @@ export async function readRuntimeHostSessions( kind: 'list_continue', revision, cursor, - ...(filter ? { filter } : {}), }); return result.kind === 'page' ? result : null; }, diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index b3ea195567..f2193d743d 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -71,7 +71,7 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 23 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 24 as const; // Transcript pages amortize storage and network round trips with a 512 KiB raw // payload. Base64 expansion plus the bounded fragment envelope must still fit in // one transport message; narrower domains retain their own encoded limits. diff --git a/packages/runtime-host/src/protocol/session-catalog.ts b/packages/runtime-host/src/protocol/session-catalog.ts index ed6f78a4da..b85fcc72cf 100644 --- a/packages/runtime-host/src/protocol/session-catalog.ts +++ b/packages/runtime-host/src/protocol/session-catalog.ts @@ -106,17 +106,10 @@ const PROJECTION_FIELDS = [ export type SessionCatalogRevision = `sha256:${string}`; -export interface SessionCatalogFilter { - readonly isArchived?: boolean; - readonly isFlagged?: boolean; - readonly labelSlug?: string; -} - export type SessionCatalogQueryInput = - | { readonly kind: 'list_start'; readonly filter?: SessionCatalogFilter } + | { readonly kind: 'list_start' } | { readonly kind: 'list_continue'; - readonly filter?: SessionCatalogFilter; readonly revision: SessionCatalogRevision; readonly cursor: string; } @@ -383,27 +376,17 @@ export function decodeExecutionBoundarySummary(value: unknown): ExecutionBoundar export function decodeSessionCatalogQueryInput(value: unknown): SessionCatalogQueryInput { const input = requireRecord(value, 'Session catalog query input'); if (input.kind === 'list_start') { - const exact = requireShapedRecord( - input, - 'Session catalog list start input', - ['kind'], - ['filter'], - ); - return { - kind: 'list_start', - ...(Object.hasOwn(exact, 'filter') ? { filter: decodeFilter(exact.filter) } : {}), - }; + requireExactRecord(input, 'Session catalog list start input', ['kind']); + return { kind: 'list_start' }; } if (input.kind === 'list_continue') { - const exact = requireShapedRecord( - input, - 'Session catalog list continuation input', - ['kind', 'revision', 'cursor'], - ['filter'], - ); + const exact = requireExactRecord(input, 'Session catalog list continuation input', [ + 'kind', + 'revision', + 'cursor', + ]); return { kind: 'list_continue', - ...(Object.hasOwn(exact, 'filter') ? { filter: decodeFilter(exact.filter) } : {}), revision: catalogRevision(exact.revision), cursor: requireUtf8String( exact.cursor, @@ -698,32 +681,6 @@ export function decodeSessionCatalogItem(value: unknown): SessionCatalogItem { }; } -function decodeFilter(value: unknown): SessionCatalogFilter { - const filter = requireShapedRecord( - value, - 'Session catalog filter', - [], - ['isArchived', 'isFlagged', 'labelSlug'], - ); - return { - ...(Object.hasOwn(filter, 'isArchived') - ? { isArchived: boolean(filter.isArchived, 'Session archived filter') } - : {}), - ...(Object.hasOwn(filter, 'isFlagged') - ? { isFlagged: boolean(filter.isFlagged, 'Session flagged filter') } - : {}), - ...(Object.hasOwn(filter, 'labelSlug') - ? { - labelSlug: boundedText( - filter.labelSlug, - 'Session label filter', - SESSION_CATALOG_LABEL_MAX_BYTES, - ), - } - : {}), - }; -} - function modelTarget(value: unknown): SessionModelTarget { const target = requireRecord(value, 'Session model target'); if (target.kind === 'default') { diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index 09ba84d99b..b572ef3d2e 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -38,7 +38,6 @@ import { SESSION_CATALOG_RESULT_MAX_BYTES, type OperationError, type OperationOutcome, - type SessionCatalogFilter, type SessionCatalogItem, type SessionCatalogProjection, type SessionCatalogQueryInput, @@ -189,15 +188,8 @@ export class HostSessionCatalogCoordinator { if (input.kind === 'list_continue' && cursor === undefined) { return queryFailure('invalid_request', 'Session cursor is invalid'); } - const filter = - input.kind === 'list_start' - ? canonicalFilter(input.filter) - : resolveContinuationFilter(input.filter, cursor); - if (filter === undefined) { - return queryFailure('invalid_request', 'Session cursor filter does not match'); - } const pageResult = await this.#stores.listCatalogPage( - filter, + undefined, cursor, SESSION_CATALOG_PAGE_MAX_ITEMS, input.kind === 'list_continue' ? input.revision : undefined, @@ -209,9 +201,7 @@ export class HostSessionCatalogCoordinator { actualRevision: pageResult.actualRevision, }); } - return successQuery( - page(pageResult.records, pageResult.revision, pageResult.hasMore, filter), - ); + return successQuery(page(pageResult.records, pageResult.revision, pageResult.hasMore)); } catch { return queryFailure('persistence_failed', 'Session catalog is unavailable'); } @@ -943,7 +933,6 @@ function page( records: readonly SessionCatalogRecord[], revision: SessionCatalogRevision, hasMore: boolean, - filter: SessionCatalogFilter, ): SessionCatalogQueryResult { const items: SessionCatalogItem[] = []; for (let index = 0; index < records.length; index += 1) { @@ -955,7 +944,7 @@ function page( kind: 'page' as const, revision, sessions: [...items, item], - nextCursor: moreItems ? encodeCursor(record, filter) : null, + nextCursor: moreItems ? encodeCursor(record) : null, }; if (Buffer.byteLength(JSON.stringify(candidate), 'utf8') > SESSION_CATALOG_RESULT_MAX_BYTES) { break; @@ -971,25 +960,22 @@ function page( kind: 'page', revision, sessions: items, - nextCursor: moreItems && lastRecord ? encodeCursor(lastRecord, filter) : null, + nextCursor: moreItems && lastRecord ? encodeCursor(lastRecord) : null, }; } -function encodeCursor(record: SessionCatalogRecord, filter: SessionCatalogFilter): string { +function encodeCursor(record: SessionCatalogRecord): string { return Buffer.from( JSON.stringify({ version: 1, activityAt: catalogActivityAt(record.header), sessionId: record.header.id, - filter, }), 'utf8', ).toString('base64url'); } -interface DecodedSessionCatalogCursor extends SessionCatalogPageCursor { - readonly filter: SessionCatalogFilter; -} +type DecodedSessionCatalogCursor = SessionCatalogPageCursor; function decodeCursor(cursor: string): DecodedSessionCatalogCursor | undefined { if (!/^[A-Za-z0-9_-]+$/.test(cursor)) return undefined; @@ -1001,7 +987,7 @@ function decodeCursor(cursor: string): DecodedSessionCatalogCursor | undefined { typeof value !== 'object' || value === null || Array.isArray(value) || - Object.keys(value).sort().join(',') !== 'activityAt,filter,sessionId,version' + Object.keys(value).sort().join(',') !== 'activityAt,sessionId,version' ) { return undefined; } @@ -1011,69 +997,19 @@ function decodeCursor(cursor: string): DecodedSessionCatalogCursor | undefined { !Number.isSafeInteger(record.activityAt) || (record.activityAt as number) < 0 || typeof record.sessionId !== 'string' || - !/^[A-Za-z0-9_-]{1,128}$/.test(record.sessionId) || - !isCursorFilter(record.filter) + !/^[A-Za-z0-9_-]{1,128}$/.test(record.sessionId) ) { return undefined; } return { activityAt: record.activityAt as number, sessionId: record.sessionId, - filter: record.filter, }; } catch { return undefined; } } -function canonicalFilter(filter: SessionCatalogFilter | undefined): SessionCatalogFilter { - return { - ...(filter?.isArchived === undefined ? {} : { isArchived: filter.isArchived }), - ...(filter?.isFlagged === undefined ? {} : { isFlagged: filter.isFlagged }), - ...(filter?.labelSlug === undefined ? {} : { labelSlug: filter.labelSlug }), - }; -} - -function resolveContinuationFilter( - requested: SessionCatalogFilter | undefined, - cursor: DecodedSessionCatalogCursor | undefined, -): SessionCatalogFilter | undefined { - if (!cursor) return undefined; - if (requested === undefined) return cursor.filter; - const filter = canonicalFilter(requested); - return filtersEqual(filter, cursor.filter) ? filter : undefined; -} - -function filtersEqual(left: SessionCatalogFilter, right: SessionCatalogFilter): boolean { - return ( - left.isArchived === right.isArchived && - left.isFlagged === right.isFlagged && - left.labelSlug === right.labelSlug - ); -} - -function isCursorFilter(value: unknown): value is SessionCatalogFilter { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; - const record = value as Record; - if ( - Object.keys(record).some( - (key) => key !== 'isArchived' && key !== 'isFlagged' && key !== 'labelSlug', - ) || - (Object.hasOwn(record, 'isArchived') && typeof record.isArchived !== 'boolean') || - (Object.hasOwn(record, 'isFlagged') && typeof record.isFlagged !== 'boolean') - ) { - return false; - } - if (!Object.hasOwn(record, 'labelSlug')) return true; - return ( - typeof record.labelSlug === 'string' && - record.labelSlug.length > 0 && - Buffer.byteLength(record.labelSlug, 'utf8') <= SESSION_CATALOG_LABEL_MAX_BYTES && - record.labelSlug.trim() === record.labelSlug && - !/[\u0000-\u001f\u007f]/.test(record.labelSlug) - ); -} - function catalogActivityAt(header: SessionHeader): number { return header.lastMessageAt ?? header.lastUsedAt ?? header.createdAt; } diff --git a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts index 247bbdf71d..01e9cc8edf 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -125,6 +125,18 @@ describe('SqliteSessionMetadataStore', () => { schema.prepare('PRAGMA foreign_key_list(agent_graph_client_terminal_activity)').all(), [], ); + assert.deepEqual( + schema + .prepare(` + SELECT name + FROM sqlite_schema + WHERE type = 'table' + AND name IN ('session_metadata_labels', 'session_catalog_label_projection') + ORDER BY name + `) + .all(), + [], + ); } finally { schema.close(); } @@ -222,7 +234,7 @@ describe('SqliteSessionMetadataStore', () => { const migrationStartedAt = Date.now(); const migrated = createSqliteSessionMetadataStore(path, { now: () => 20 }); try { - assert.equal(migrated.schemaVersion(), 25); + assert.equal(migrated.schemaVersion(), SQLITE_SESSION_METADATA_SCHEMA_VERSION); assert.deepEqual((await migrated.read('legacy-review')).header.status, 'active'); assert.deepEqual((await migrated.read('legacy-done')).header.status, 'active'); assert.deepEqual((await migrated.read('legacy-both')).header.status, 'active'); @@ -1197,7 +1209,7 @@ describe('SqliteSessionMetadataStore', () => { } }); - test('filters indexed flags, archive state, and normalized labels in recency order', async () => { + test('lists sessions in recency order with readable flags, archive state, and labels', async () => { const store = createSqliteSessionMetadataStore(':memory:'); try { await store.create( @@ -1233,19 +1245,25 @@ describe('SqliteSessionMetadataStore', () => { }), ); + const listed = await store.list(); assert.deepEqual( - (await store.list({ isArchived: false })).map((record) => record.header.id), - ['newer', 'older'], + listed.map((record) => record.header.id), + ['archived', 'newer', 'older'], ); assert.deepEqual( - (await store.list({ isArchived: false, isFlagged: true, labelSlug: 'shared' })).map( - (record) => record.header.id, - ), - ['newer', 'older'], + listed.map((record) => record.header.labels), + [['shared'], ['shared'], ['alpha', 'shared']], ); assert.deepEqual( - (await store.list({ labelSlug: 'alpha' })).map((record) => record.header.id), - ['older'], + listed.map((record) => ({ + archived: record.header.isArchived, + flagged: record.header.isFlagged, + })), + [ + { archived: true, flagged: false }, + { archived: false, flagged: true }, + { archived: false, flagged: true }, + ], ); } finally { store.close(); @@ -1486,11 +1504,7 @@ describe('SqliteSessionMetadataStore', () => { assert.equal(updated.header.name, 'Renamed'); assert.deepEqual(updated.header.labels, ['replacement']); assert.equal(updated.header.lastReadMessageId, 'message-2'); - assert.deepEqual( - (await store.list({ labelSlug: 'replacement' })).map((record) => record.header.id), - ['session-1'], - ); - assert.deepEqual(await store.list({ labelSlug: 'alpha' }), []); + assert.deepEqual((await store.read('session-1')).header.labels, ['replacement']); await assert.rejects( () => store.update('session-1', { name: 'Stale' }, { expectedVersion: 1 }), @@ -1703,10 +1717,9 @@ describe('SqliteSessionMetadataStore', () => { } }); - test('rolls back row and label changes at every injected transaction failure', async () => { + test('rolls back row changes at every injected transaction failure', async () => { for (const failpoint of [ 'after_session_row_write', - 'after_session_labels_write', ] satisfies SqliteSessionMetadataStoreFailpoint[]) { let armed = true; const store = createSqliteSessionMetadataStore(':memory:', { @@ -1729,14 +1742,13 @@ describe('SqliteSessionMetadataStore', () => { assert.equal(current.metadataVersion, 1); assert.equal(current.header.name, 'Session'); assert.deepEqual(current.header.labels, ['alpha', 'beta']); - assert.deepEqual(await store.list({ labelSlug: 'lost' }), []); } finally { store.close(); } } }); - test('deletes metadata and its label projection atomically', async () => { + test('deletes metadata atomically', async () => { const store = createSqliteSessionMetadataStore(':memory:'); try { await store.create(fullHeader()); @@ -1744,7 +1756,6 @@ describe('SqliteSessionMetadataStore', () => { assert.equal(await store.remove('session-1'), false); assert.equal(await store.has('session-1'), false); assert.equal(await store.isTombstoned('session-1'), true); - assert.deepEqual(await store.list({ labelSlug: 'alpha' }), []); await assert.rejects(() => store.create(fullHeader()), /tombstoned/); } finally { store.close(); diff --git a/packages/storage/src/sqlite-session-catalog-query.ts b/packages/storage/src/sqlite-session-catalog-query.ts index d5fe2aa081..3ae296a8ff 100644 --- a/packages/storage/src/sqlite-session-catalog-query.ts +++ b/packages/storage/src/sqlite-session-catalog-query.ts @@ -14,56 +14,29 @@ export function buildSqliteSessionCatalogPageQuery( filter: SessionListFilter, cursor: SqliteSessionCatalogCursor | undefined, ): SqliteSessionCatalogPageQuery { - const usesLabel = filter.labelSlug !== undefined; - const orderBy = usesLabel ? 'selected_label' : 'projection'; const where: string[] = []; const parameters: Array = []; where.push( "COALESCE(json_extract(metadata.payload_json, '$.conversationCopy.state'), '') <> 'preparing'", ); where.push("COALESCE(json_extract(metadata.payload_json, '$.transcriptLedgerVersion'), 1) <> 0"); - if (filter.labelSlug !== undefined) { - where.push('selected_label.label = ?'); - parameters.push(filter.labelSlug); - } - if (filter.isArchived !== undefined) { - where.push('projection.is_archived = ?'); - parameters.push(filter.isArchived ? 1 : 0); - } - if (filter.isFlagged !== undefined) { - where.push('projection.is_flagged = ?'); - parameters.push(filter.isFlagged ? 1 : 0); - } if (filter.subagentParentSessionId !== undefined) { where.push('projection.subagent_parent_session_id = ?'); parameters.push(filter.subagentParentSessionId); } if (cursor) { - where.push(`${orderBy}.activity_at <= ?`); + where.push('projection.activity_at <= ?'); where.push(` ( - ${orderBy}.activity_at < ? + projection.activity_at < ? OR ( - ${orderBy}.activity_at = ? - AND ${orderBy}.session_id > ? + projection.activity_at = ? + AND projection.session_id > ? ) ) `); parameters.push(cursor.activityAt, cursor.activityAt, cursor.activityAt, cursor.sessionId); } - const from = usesLabel - ? ` - FROM session_catalog_label_projection selected_label - JOIN session_catalog_projection projection - ON projection.session_id = selected_label.session_id - JOIN session_metadata metadata - ON metadata.session_id = projection.session_id - ` - : ` - FROM session_catalog_projection projection - JOIN session_metadata metadata - ON metadata.session_id = projection.session_id - `; return { sql: ` SELECT @@ -72,9 +45,11 @@ export function buildSqliteSessionCatalogPageQuery( metadata.metadata_version, metadata.committed_at, projection.last_message_preview - ${from} + FROM session_catalog_projection projection + JOIN session_metadata metadata + ON metadata.session_id = projection.session_id ${where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''} - ORDER BY ${orderBy}.activity_at DESC, ${orderBy}.session_id ASC + ORDER BY projection.activity_at DESC, projection.session_id ASC LIMIT ? `, parameters, diff --git a/packages/storage/src/sqlite-session-metadata-schema.ts b/packages/storage/src/sqlite-session-metadata-schema.ts index 6f42a027dd..fce3f2a010 100644 --- a/packages/storage/src/sqlite-session-metadata-schema.ts +++ b/packages/storage/src/sqlite-session-metadata-schema.ts @@ -1,6 +1,6 @@ import type { DatabaseSync } from 'node:sqlite'; -export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 25; +export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 26; export const SQLITE_SESSION_MESSAGE_CHUNK_BYTES = 64 * 1024; export const SQLITE_SESSION_MESSAGE_CHUNK_MARKER = '{"$maka":"session-message-chunks-v1"}'; @@ -914,6 +914,40 @@ const MIGRATIONS: ReadonlyMap = new Map([ OR json_extract(payload_json, '$.status') IN ('review', 'done'); `, ], + [ + 26, + ` + DROP TRIGGER IF EXISTS session_catalog_label_after_insert; + DROP TRIGGER IF EXISTS session_catalog_label_after_delete; + DROP TRIGGER IF EXISTS session_catalog_after_update; + DROP INDEX IF EXISTS session_catalog_labels_by_label_activity; + DROP TABLE IF EXISTS session_catalog_label_projection; + DROP INDEX IF EXISTS session_catalog_by_archived_activity; + DROP INDEX IF EXISTS session_catalog_by_flagged_activity; + DROP INDEX IF EXISTS session_catalog_by_archived_flagged_activity; + DROP INDEX IF EXISTS session_metadata_by_flag; + + CREATE TRIGGER session_catalog_after_update + AFTER UPDATE ON session_metadata + BEGIN + UPDATE session_catalog_projection + SET + activity_at = COALESCE(NEW.last_message_at, NEW.last_used_at, NEW.created_at), + last_message_at = NEW.last_message_at, + is_archived = NEW.is_archived, + is_flagged = NEW.is_flagged, + subagent_parent_session_id = NEW.subagent_parent_session_id + WHERE session_id = NEW.session_id; + + UPDATE session_catalog_state + SET generation = generation + 1 + WHERE scope = 'catalog'; + END; + + DROP INDEX IF EXISTS session_metadata_labels_by_label; + DROP TABLE IF EXISTS session_metadata_labels; + `, + ], ]); export function configureSqliteSessionMetadataDatabase(db: DatabaseSync): void { diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index 7ab67d07db..2dc467bca0 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -150,7 +150,6 @@ function loadSqliteModule(): typeof import('node:sqlite') { export type SqliteSessionMetadataStoreFailpoint = | 'after_session_row_write' - | 'after_session_labels_write' | 'after_agent_graph_intent_claim_write' | 'after_agent_graph_schedule_update_write' | 'after_agent_graph_operator_provision_write' @@ -3543,8 +3542,6 @@ export class SqliteSessionMetadataStore { ); if (result.changes !== 1) return undefined; this.options.failpoint?.('after_session_row_write'); - this.replaceLabels(header); - this.options.failpoint?.('after_session_labels_write'); this.ensureGenesisExecutionBoundary(header, initialBoundary); return { header, metadataVersion, committedAt }; } @@ -3727,7 +3724,6 @@ export class SqliteSessionMetadataStore { if (next.id !== sessionId) { throw new SessionMetadataConflictError('Session metadata identity cannot be changed'); } - const labelsChanged = !isDeepStrictEqual(next.labels, current.header.labels); const currentPreview = options.catalogPreview === undefined ? undefined : this.readCatalogPreviewSync(sessionId); const previewChanged = @@ -3793,10 +3789,6 @@ export class SqliteSessionMetadataStore { ); } this.options.failpoint?.('after_session_row_write'); - if (labelsChanged) { - this.replaceLabels(next); - this.options.failpoint?.('after_session_labels_write'); - } if (options.catalogPreview) { const preview = this.db .prepare( @@ -3924,17 +3916,6 @@ export class SqliteSessionMetadataStore { return { boundary, record: updated }; } - private replaceLabels(header: SessionHeader): void { - this.db.prepare('DELETE FROM session_metadata_labels WHERE session_id = ?').run(header.id); - const insert = this.db.prepare(` - INSERT INTO session_metadata_labels(session_id, label_index, label) - VALUES (?, ?, ?) - `); - for (let index = 0; index < header.labels.length; index += 1) { - insert.run(header.id, index, header.labels[index]!); - } - } - private readRecordSync(sessionId: string): SessionMetadataRecord | undefined { const row = this.db .prepare( @@ -4810,25 +4791,6 @@ function buildSessionListPredicate(filter: SessionListFilter): { } { const where: string[] = []; const parameters: Array = []; - if (filter.isArchived !== undefined) { - where.push('metadata.is_archived = ?'); - parameters.push(filter.isArchived ? 1 : 0); - } - if (filter.isFlagged !== undefined) { - where.push('metadata.is_flagged = ?'); - parameters.push(filter.isFlagged ? 1 : 0); - } - if (filter.labelSlug !== undefined) { - where.push(` - EXISTS ( - SELECT 1 - FROM session_metadata_labels labels - WHERE labels.session_id = metadata.session_id - AND labels.label = ? - ) - `); - parameters.push(filter.labelSlug); - } if (filter.subagentParentSessionId !== undefined) { assertSafeSessionId(filter.subagentParentSessionId); where.push('metadata.subagent_parent_session_id = ?');