mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
refactor(sync): replace message reloads with incremental tail sync
This commit is contained in:
@@ -243,6 +243,11 @@ describe('MessageService message pagination', () => {
|
||||
expect(page.messages.map((message) => message.id)).toEqual([second.id, third.id])
|
||||
expect(page.page.nextBeforeAt).toBe(2_000)
|
||||
expect(page.page.nextBeforeSeq).toBe(second.seq)
|
||||
expect(page.page.direction).toBe('latest')
|
||||
expect(page.page.epoch).toBe(0)
|
||||
expect(page.page.reset).toBe(false)
|
||||
expect(page.page.snapshotHeadAt).toBe(3_000)
|
||||
expect(page.page.snapshotHeadSeq).toBe(third.seq)
|
||||
expect(page.page.hasMore).toBe(true)
|
||||
expect(first.id).toBeDefined()
|
||||
})
|
||||
@@ -266,6 +271,7 @@ describe('MessageService message pagination', () => {
|
||||
expect(older.messages.map((message) => message.id)).toEqual([first.id])
|
||||
expect(older.page.nextBeforeAt).toBe(1_000)
|
||||
expect(older.page.nextBeforeSeq).toBe(first.seq)
|
||||
expect(older.page.direction).toBe('before')
|
||||
expect(older.page.hasMore).toBe(false)
|
||||
expect(second.id).toBeDefined()
|
||||
expect(third.id).toBeDefined()
|
||||
@@ -305,6 +311,97 @@ describe('MessageService message pagination', () => {
|
||||
expect(page.page.nextBeforeSeq).toBe(invoked.seq)
|
||||
expect(page.page.hasMore).toBe(true)
|
||||
})
|
||||
|
||||
it('pages forward to a fixed snapshot head', () => {
|
||||
const store = makeStore()
|
||||
const session = makeSession(store, 'page-after')
|
||||
const first = store.messages.addMessage(session.id, 'first', 'local-first')
|
||||
const second = store.messages.addMessage(session.id, 'second', 'local-second')
|
||||
const third = store.messages.addMessage(session.id, 'third', 'local-third')
|
||||
const fourth = store.messages.addMessage(session.id, 'fourth', 'local-fourth')
|
||||
store.messages.markMessagesInvoked(session.id, ['local-first'], 1_000)
|
||||
store.messages.markMessagesInvoked(session.id, ['local-second'], 2_000)
|
||||
store.messages.markMessagesInvoked(session.id, ['local-third'], 3_000)
|
||||
store.messages.markMessagesInvoked(session.id, ['local-fourth'], 4_000)
|
||||
|
||||
const service = makeService(store)
|
||||
const firstDelta = service.getMessagesPage(session.id, {
|
||||
limit: 1,
|
||||
after: { at: 1_000, seq: first.seq },
|
||||
epoch: 0
|
||||
})
|
||||
|
||||
expect(firstDelta.messages.map((message) => message.id)).toEqual([second.id])
|
||||
expect(firstDelta.page).toMatchObject({
|
||||
direction: 'after',
|
||||
nextAfterAt: 2_000,
|
||||
nextAfterSeq: second.seq,
|
||||
snapshotHeadAt: 4_000,
|
||||
snapshotHeadSeq: fourth.seq,
|
||||
hasMore: true,
|
||||
reset: false
|
||||
})
|
||||
|
||||
const fifth = store.messages.addMessage(session.id, 'fifth', 'local-fifth')
|
||||
store.messages.markMessagesInvoked(session.id, ['local-fifth'], 5_000)
|
||||
|
||||
const secondDelta = service.getMessagesPage(session.id, {
|
||||
limit: 10,
|
||||
after: { at: firstDelta.page.nextAfterAt!, seq: firstDelta.page.nextAfterSeq! },
|
||||
until: { at: firstDelta.page.snapshotHeadAt!, seq: firstDelta.page.snapshotHeadSeq! },
|
||||
epoch: firstDelta.page.epoch
|
||||
})
|
||||
|
||||
expect(secondDelta.messages.map((message) => message.id)).toEqual([third.id, fourth.id])
|
||||
expect(secondDelta.page.hasMore).toBe(false)
|
||||
expect(secondDelta.messages.some((message) => message.id === fifth.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('completes a fixed snapshot when no raw row remains before its head', () => {
|
||||
const store = makeStore()
|
||||
const session = makeSession(store, 'page-after-empty-snapshot-gap')
|
||||
const first = store.messages.addMessage(session.id, 'first', 'local-first')
|
||||
const latest = store.messages.addMessage(session.id, 'latest', 'local-latest')
|
||||
store.messages.markMessagesInvoked(session.id, ['local-first'], 1_000)
|
||||
store.messages.markMessagesInvoked(session.id, ['local-latest'], 4_000)
|
||||
|
||||
const response = makeService(store).getMessagesPage(session.id, {
|
||||
limit: 10,
|
||||
after: { at: 2_000, seq: first.seq },
|
||||
until: { at: 3_000, seq: latest.seq },
|
||||
epoch: 0
|
||||
})
|
||||
|
||||
expect(response.messages).toEqual([])
|
||||
expect(response.page).toMatchObject({
|
||||
direction: 'after',
|
||||
nextAfterAt: 3_000,
|
||||
nextAfterSeq: latest.seq,
|
||||
snapshotHeadAt: 3_000,
|
||||
snapshotHeadSeq: latest.seq,
|
||||
hasMore: false
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a reset latest page when the structural epoch changed', () => {
|
||||
const store = makeStore()
|
||||
const session = makeSession(store, 'page-after-reset')
|
||||
const first = store.messages.addMessage(session.id, 'first', 'local-first')
|
||||
store.messages.markMessagesInvoked(session.id, ['local-first'], 1_000)
|
||||
const queued = store.messages.addMessage(session.id, 'queued', 'local-queued')
|
||||
store.messages.cancelQueuedMessage(session.id, queued.id)
|
||||
|
||||
const response = makeService(store).getMessagesPage(session.id, {
|
||||
limit: 10,
|
||||
after: { at: 1_000, seq: first.seq },
|
||||
epoch: 0
|
||||
})
|
||||
|
||||
expect(response.page.direction).toBe('latest')
|
||||
expect(response.page.reset).toBe(true)
|
||||
expect(response.page.epoch).toBe(1)
|
||||
expect(response.messages.map((message) => message.id)).toEqual([first.id])
|
||||
})
|
||||
})
|
||||
|
||||
describe('MessageService.getQueuedState', () => {
|
||||
|
||||
+121
-14
@@ -10,13 +10,25 @@ import {
|
||||
unwrapRoleWrappedRecordEnvelope
|
||||
} from '@hapi/protocol/messages'
|
||||
import { isObject } from '@hapi/protocol'
|
||||
import type { QueuedStateResponse } from '@hapi/protocol/apiTypes'
|
||||
import type { MessagesResponse, QueuedStateResponse } from '@hapi/protocol/apiTypes'
|
||||
import type { Server } from 'socket.io'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Store, CancelQueuedMessageResult } from '../store'
|
||||
import { EventPublisher } from './eventPublisher'
|
||||
|
||||
type StoredMessageForDelivery = ReturnType<Store['messages']['getMessages']>[number]
|
||||
type MessagePosition = { at: number; seq: number }
|
||||
|
||||
function messagePosition(message: StoredMessageForDelivery): MessagePosition {
|
||||
return {
|
||||
at: message.invokedAt ?? message.createdAt,
|
||||
seq: message.seq
|
||||
}
|
||||
}
|
||||
|
||||
function comparePosition(a: MessagePosition, b: MessagePosition): number {
|
||||
return a.at !== b.at ? a.at - b.at : a.seq - b.seq
|
||||
}
|
||||
|
||||
function isWebVisibleStoredMessage(message: StoredMessageForDelivery): boolean {
|
||||
return !isRedundantGoalStatusEventContent(message.content)
|
||||
@@ -139,25 +151,54 @@ export class MessageService {
|
||||
|
||||
getMessagesPage(
|
||||
sessionId: string,
|
||||
options: { limit: number; before?: { at: number; seq: number } | null }
|
||||
): {
|
||||
messages: DecryptedMessage[]
|
||||
page: {
|
||||
options: {
|
||||
limit: number
|
||||
nextBeforeSeq: number | null
|
||||
nextBeforeAt: number | null
|
||||
hasMore: boolean
|
||||
before?: MessagePosition | null
|
||||
after?: MessagePosition | null
|
||||
until?: MessagePosition | null
|
||||
epoch?: number | null
|
||||
}
|
||||
} {
|
||||
let before = options.before ?? undefined
|
||||
let pageRows = this.store.messages.getMessagesByPosition(sessionId, options.limit, before)
|
||||
): MessagesResponse {
|
||||
const epoch = this.store.messages.getMessageEpoch(sessionId)
|
||||
if (options.after) {
|
||||
if (options.epoch !== undefined && options.epoch !== null && options.epoch !== epoch) {
|
||||
return this.getLatestOrBeforeMessagesPage(sessionId, options.limit, null, epoch, true)
|
||||
}
|
||||
return this.getAfterMessagesPage(
|
||||
sessionId,
|
||||
options.limit,
|
||||
options.after,
|
||||
options.until ?? null,
|
||||
epoch
|
||||
)
|
||||
}
|
||||
return this.getLatestOrBeforeMessagesPage(
|
||||
sessionId,
|
||||
options.limit,
|
||||
options.before ?? null,
|
||||
epoch,
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
private getLatestOrBeforeMessagesPage(
|
||||
sessionId: string,
|
||||
limit: number,
|
||||
requestedBefore: MessagePosition | null,
|
||||
epoch: number,
|
||||
reset: boolean
|
||||
): MessagesResponse {
|
||||
const direction = requestedBefore ? 'before' as const : 'latest' as const
|
||||
const snapshotHead = this.store.messages.getNewestMessagePosition(sessionId)
|
||||
let before = requestedBefore ?? undefined
|
||||
let pageRows = this.store.messages.getMessagesByPosition(sessionId, limit, requestedBefore ?? undefined)
|
||||
|
||||
// Latest-page request (no cursor): also include uninvoked local user messages
|
||||
// out-of-band, so refresh / secondary clients can still see queued rows even
|
||||
// when their position key (createdAt) places them outside the latest page.
|
||||
// The cursor stays anchored to pageRows so out-of-band rows don't affect
|
||||
// pagination of older pages.
|
||||
let queuedRows = before === undefined
|
||||
let queuedRows = requestedBefore === null
|
||||
? this.store.messages.getUninvokedLocalMessages(sessionId)
|
||||
: []
|
||||
|
||||
@@ -190,7 +231,7 @@ export class MessageService {
|
||||
|
||||
while (messages.length === 0 && hasMore && oldestSeq !== null && oldestPositionAt !== null) {
|
||||
before = { at: oldestPositionAt, seq: oldestSeq }
|
||||
pageRows = this.store.messages.getMessagesByPosition(sessionId, options.limit, before)
|
||||
pageRows = this.store.messages.getMessagesByPosition(sessionId, limit, before)
|
||||
queuedRows = []
|
||||
|
||||
byId = new Map<string, typeof pageRows[number]>()
|
||||
@@ -219,9 +260,75 @@ export class MessageService {
|
||||
return {
|
||||
messages,
|
||||
page: {
|
||||
limit: options.limit,
|
||||
direction,
|
||||
limit,
|
||||
epoch,
|
||||
reset,
|
||||
nextBeforeSeq: oldestSeq,
|
||||
nextBeforeAt: oldestPositionAt,
|
||||
nextAfterSeq: null,
|
||||
nextAfterAt: null,
|
||||
snapshotHeadSeq: snapshotHead?.seq ?? null,
|
||||
snapshotHeadAt: snapshotHead?.at ?? null,
|
||||
hasMore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getAfterMessagesPage(
|
||||
sessionId: string,
|
||||
limit: number,
|
||||
after: MessagePosition,
|
||||
requestedUntil: MessagePosition | null,
|
||||
epoch: number
|
||||
): MessagesResponse {
|
||||
const currentHead = this.store.messages.getNewestMessagePosition(sessionId)
|
||||
const snapshotHead = currentHead && requestedUntil
|
||||
? (comparePosition(requestedUntil, currentHead) <= 0 ? requestedUntil : currentHead)
|
||||
: requestedUntil ?? currentHead
|
||||
|
||||
if (!snapshotHead || comparePosition(snapshotHead, after) <= 0) {
|
||||
return {
|
||||
messages: [],
|
||||
page: {
|
||||
direction: 'after',
|
||||
limit,
|
||||
epoch,
|
||||
reset: false,
|
||||
nextBeforeSeq: null,
|
||||
nextBeforeAt: null,
|
||||
nextAfterSeq: after.seq,
|
||||
nextAfterAt: after.at,
|
||||
snapshotHeadSeq: snapshotHead?.seq ?? null,
|
||||
snapshotHeadAt: snapshotHead?.at ?? null,
|
||||
hasMore: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pageRows = this.store.messages.getMessagesAfterPosition(
|
||||
sessionId,
|
||||
limit,
|
||||
after,
|
||||
snapshotHead
|
||||
)
|
||||
const last = pageRows[pageRows.length - 1] ?? null
|
||||
const nextAfter = last ? messagePosition(last) : snapshotHead
|
||||
const hasMore = last !== null && comparePosition(nextAfter, snapshotHead) < 0
|
||||
|
||||
return {
|
||||
messages: toVisibleDecryptedMessages(pageRows),
|
||||
page: {
|
||||
direction: 'after',
|
||||
limit,
|
||||
epoch,
|
||||
reset: false,
|
||||
nextBeforeSeq: null,
|
||||
nextBeforeAt: null,
|
||||
nextAfterSeq: nextAfter.seq,
|
||||
nextAfterAt: nextAfter.at,
|
||||
snapshotHeadSeq: snapshotHead.seq,
|
||||
snapshotHeadAt: snapshotHead.at,
|
||||
hasMore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { isKnownFlavor, type LocalResumeTarget, type ResumableSession } from '@hapi/protocol'
|
||||
import type { CursorChatStoreStatus, CursorMigrateOutcome, CursorMigrateToAcpRequest, QueuedStateResponse, SlashCommandsResponse } from '@hapi/protocol/apiTypes'
|
||||
import type { CursorChatStoreStatus, CursorMigrateOutcome, CursorMigrateToAcpRequest, MessagesResponse, QueuedStateResponse, SlashCommandsResponse } from '@hapi/protocol/apiTypes'
|
||||
import type { AgentFlavor, CodexCollaborationMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types'
|
||||
import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages'
|
||||
import type { Server } from 'socket.io'
|
||||
@@ -323,16 +323,14 @@ export class SyncEngine {
|
||||
|
||||
getMessagesPage(
|
||||
sessionId: string,
|
||||
options: { limit: number; before?: { at: number; seq: number } | null }
|
||||
): {
|
||||
messages: DecryptedMessage[]
|
||||
page: {
|
||||
options: {
|
||||
limit: number
|
||||
nextBeforeSeq: number | null
|
||||
nextBeforeAt: number | null
|
||||
hasMore: boolean
|
||||
before?: { at: number; seq: number } | null
|
||||
after?: { at: number; seq: number } | null
|
||||
until?: { at: number; seq: number } | null
|
||||
epoch?: number | null
|
||||
}
|
||||
} {
|
||||
): MessagesResponse {
|
||||
return this.messageService.getMessagesPage(sessionId, options)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user