mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-02 23:51:34 +00:00
merge: B-M2c Android message window store, pagination fixtures green
This commit is contained in:
@@ -93,7 +93,7 @@ class HapiApi(
|
||||
private val client: OkHttpClient,
|
||||
private val imageClient: OkHttpClient = client,
|
||||
private val authClient: OkHttpClient = client,
|
||||
) {
|
||||
) : MessagesApi {
|
||||
/** Normalized hub origin this instance talks to. */
|
||||
val hubUrl: String = HubUrls.normalize(hubUrl)
|
||||
?: throw IllegalArgumentException("Invalid hub URL: $hubUrl")
|
||||
@@ -193,6 +193,26 @@ class HapiApi(
|
||||
return request("GET", target.build())
|
||||
}
|
||||
|
||||
/** [MessagesApi] seam over [getMessages] — the window store's transport. */
|
||||
override suspend fun getMessages(sessionId: String, query: MessagesQuery): MessagesResponse = when (query) {
|
||||
is MessagesQuery.Latest -> getMessages(sessionId, limit = query.limit)
|
||||
is MessagesQuery.Before -> getMessages(
|
||||
sessionId,
|
||||
limit = query.limit,
|
||||
beforeSeq = query.beforeSeq,
|
||||
beforeAt = query.beforeAt,
|
||||
)
|
||||
is MessagesQuery.After -> getMessages(
|
||||
sessionId,
|
||||
limit = query.limit,
|
||||
afterSeq = query.afterSeq,
|
||||
afterAt = query.afterAt,
|
||||
untilSeq = query.untilSeq,
|
||||
untilAt = query.untilAt,
|
||||
epoch = query.epoch,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /api/sessions/:id/messages` — responds `{ok: true}` only; the
|
||||
* message itself arrives via SSE (`message-received`), reconciled by
|
||||
@@ -211,7 +231,7 @@ class HapiApi(
|
||||
request("POST", url("api", "sessions", sessionId, "messages", messageId, "steer").build(), EMPTY_JSON)
|
||||
|
||||
/** `POST /api/sessions/:id/messages/queued-state` — resync optimistic sends after reconnect. */
|
||||
suspend fun getQueuedState(sessionId: String, localIds: List<String>): QueuedStateResponse =
|
||||
override suspend fun getQueuedState(sessionId: String, localIds: List<String>): QueuedStateResponse =
|
||||
request(
|
||||
"POST",
|
||||
url("api", "sessions", sessionId, "messages", "queued-state").build(),
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package app.hapi.data.api
|
||||
|
||||
import app.hapi.protocol.wire.MessagesResponse
|
||||
import app.hapi.protocol.wire.QueuedStateResponse
|
||||
|
||||
/**
|
||||
* One `GET /api/sessions/:id/messages` request. The three variants are the
|
||||
* only shapes the reference client ever issues
|
||||
* (`docs/api/client-contract/pagination.md`): the store cannot produce an
|
||||
* invalid parameter combination by construction, and the pagination fixture
|
||||
* harness asserts these against the recorded `expectedRequests` — `After`
|
||||
* always carries the `until` keys (explicitly null on the first catch-up
|
||||
* request), the other variants never do.
|
||||
*/
|
||||
sealed interface MessagesQuery {
|
||||
val limit: Int
|
||||
|
||||
/** Newest page, no cursor. */
|
||||
data class Latest(override val limit: Int) : MessagesQuery
|
||||
|
||||
/** Rows strictly older than the compound cursor. */
|
||||
data class Before(
|
||||
val beforeAt: Long,
|
||||
val beforeSeq: Long,
|
||||
override val limit: Int,
|
||||
) : MessagesQuery
|
||||
|
||||
/** Tail catch-up: rows strictly newer than the cursor, snapshot-bounded. */
|
||||
data class After(
|
||||
val afterAt: Long,
|
||||
val afterSeq: Long,
|
||||
val untilAt: Long?,
|
||||
val untilSeq: Long?,
|
||||
val epoch: Long,
|
||||
override val limit: Int,
|
||||
) : MessagesQuery
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal seam over the two message endpoints the window store drives —
|
||||
* implemented by [HapiApi] in production and by a scripted fake in the
|
||||
* pagination fixture harness. Extracted (instead of depending on the concrete
|
||||
* [HapiApi]) so the REAL orchestration is what the fixtures replay.
|
||||
*/
|
||||
interface MessagesApi {
|
||||
/** `GET /api/sessions/:id/messages`. */
|
||||
suspend fun getMessages(sessionId: String, query: MessagesQuery): MessagesResponse
|
||||
|
||||
/** `POST /api/sessions/:id/messages/queued-state`. */
|
||||
suspend fun getQueuedState(sessionId: String, localIds: List<String>): QueuedStateResponse
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
package app.hapi.data.store
|
||||
|
||||
import app.hapi.data.api.MessagesApi
|
||||
import app.hapi.data.api.MessagesQuery
|
||||
import app.hapi.protocol.window.MessagePosition
|
||||
import app.hapi.protocol.window.MessageStatus
|
||||
import app.hapi.protocol.window.MessageViewMode
|
||||
import app.hapi.protocol.window.MessageWindowLogic
|
||||
import app.hapi.protocol.window.MessageWindowState
|
||||
import app.hapi.protocol.window.OlderLoadOutcome
|
||||
import app.hapi.protocol.window.PAGE_SIZE
|
||||
import app.hapi.protocol.window.WindowMessage
|
||||
import app.hapi.protocol.window.asWindowMessage
|
||||
import app.hapi.protocol.window.buildOptimisticMessage
|
||||
import app.hapi.protocol.wire.AttachmentMetadata
|
||||
import app.hapi.protocol.wire.MessagesResponse
|
||||
import app.hapi.protocol.wire.SyncEvent
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/** What the chat screen renders — mirror of the web `useMessages` return shape. */
|
||||
data class MessageWindowUiState(
|
||||
val messages: List<WindowMessage>,
|
||||
val hasMore: Boolean,
|
||||
val isSyncingTail: Boolean,
|
||||
val isLoadingMore: Boolean,
|
||||
val warning: String?,
|
||||
val viewMode: MessageViewMode,
|
||||
val messagesVersion: Long,
|
||||
val historyVersion: Long,
|
||||
val tailRevision: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* Per-session message window orchestration — the async half of the web
|
||||
* reference `web/src/lib/message-window-store.ts`, driving the pure
|
||||
* transitions in `MessageWindowLogic` over a [StateFlow].
|
||||
*
|
||||
* Concurrency model: the web store is single-threaded JS whose interleaving
|
||||
* points are its `await`s; here every synchronous read-modify-write segment
|
||||
* between transport calls runs under [stateMutex], and the generation
|
||||
* counters + request-baseline identity checks (ported as-is) handle whatever
|
||||
* interleaves across the api calls — which are deliberately made **outside**
|
||||
* the lock. Tail syncs are single-flight per session with an optional
|
||||
* trailing re-run, exactly like the web `TailSyncController`.
|
||||
*/
|
||||
class MessageWindowStore(
|
||||
val sessionId: String,
|
||||
private val api: MessagesApi,
|
||||
private val scope: CoroutineScope,
|
||||
private val snapshots: WindowSnapshots? = null,
|
||||
initialState: MessageWindowState? = null,
|
||||
) {
|
||||
private val stateMutex = Mutex()
|
||||
private val _state = MutableStateFlow(initialState ?: MessageWindowLogic.createState(sessionId))
|
||||
|
||||
/** The full window state (UI projects what it needs; see [uiState]). */
|
||||
val state: StateFlow<MessageWindowState> = _state.asStateFlow()
|
||||
|
||||
/**
|
||||
* UI-facing projection — the fields the web `useMessages` hook returns;
|
||||
* cursor/generation internals stay off the render path.
|
||||
*/
|
||||
val uiState: Flow<MessageWindowUiState> = state
|
||||
.map { current ->
|
||||
MessageWindowUiState(
|
||||
messages = current.messages,
|
||||
hasMore = current.hasMore,
|
||||
isSyncingTail = current.isSyncingTail,
|
||||
isLoadingMore = current.isLoadingMore,
|
||||
warning = current.warning,
|
||||
viewMode = current.viewMode,
|
||||
messagesVersion = current.messagesVersion,
|
||||
historyVersion = current.historyVersion,
|
||||
tailRevision = current.tailRevision,
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
|
||||
// ------------------------------------------------------- tail controller --
|
||||
|
||||
private val controllerLock = Any()
|
||||
private var running: TailRun? = null
|
||||
private var trailingRequested = false
|
||||
|
||||
/** Bumped by [clear]/[seedFrom] so a finished run stops chaining trailing runs. */
|
||||
private var controllerEpoch = 0
|
||||
|
||||
private class TailRun(val prefersLatest: Boolean) {
|
||||
lateinit var job: Deferred<Unit>
|
||||
}
|
||||
|
||||
private sealed interface SyncDecision {
|
||||
data class Await(val job: Deferred<Unit>) : SyncDecision
|
||||
data class Drain(val observed: Deferred<Unit>) : SyncDecision
|
||||
}
|
||||
|
||||
/**
|
||||
* Run (or join) a tail sync (web `syncTailMessages`). With
|
||||
* [ensureAfterCurrent] the call drains: if a run is already in flight, a
|
||||
* trailing run is requested and awaited, so the caller returns only after
|
||||
* a sync that STARTED at or after this call.
|
||||
*/
|
||||
suspend fun syncTail(ensureAfterCurrent: Boolean = false) {
|
||||
val decision: SyncDecision = synchronized(controllerLock) {
|
||||
val current = running
|
||||
when {
|
||||
current == null -> SyncDecision.Await(startTailSyncLocked())
|
||||
_state.value.preferLatestOnActivation ->
|
||||
if (current.prefersLatest) {
|
||||
SyncDecision.Await(current.job)
|
||||
} else {
|
||||
trailingRequested = false
|
||||
SyncDecision.Await(startTailSyncLocked())
|
||||
}
|
||||
!ensureAfterCurrent -> SyncDecision.Await(current.job)
|
||||
else -> {
|
||||
trailingRequested = true
|
||||
SyncDecision.Drain(current.job)
|
||||
}
|
||||
}
|
||||
}
|
||||
when (decision) {
|
||||
is SyncDecision.Await -> decision.job.join()
|
||||
is SyncDecision.Drain -> drainTailSync(decision.observed)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startTailSyncLocked(): Deferred<Unit> {
|
||||
val epoch = controllerEpoch
|
||||
val run = TailRun(prefersLatest = _state.value.preferLatestOnActivation)
|
||||
// UNDISPATCHED mirrors the web: `runTailSync` executes to its first true
|
||||
// suspension (normally the api call) before `startTailSync` returns, so
|
||||
// `beginTailSync`'s generation bump lands synchronously and an in-flight
|
||||
// run it replaces cannot commit another page in between.
|
||||
run.job = scope.async(start = CoroutineStart.UNDISPATCHED) { runTailSync() }
|
||||
running = run
|
||||
run.job.invokeOnCompletion {
|
||||
synchronized(controllerLock) {
|
||||
if (controllerEpoch != epoch || running !== run) return@invokeOnCompletion
|
||||
running = null
|
||||
if (!trailingRequested) return@invokeOnCompletion
|
||||
trailingRequested = false
|
||||
startTailSyncLocked()
|
||||
}
|
||||
}
|
||||
return run.job
|
||||
}
|
||||
|
||||
/** Web `waitForTailSyncDrain`: follow the chain until no newer run exists. */
|
||||
private suspend fun drainTailSync(observed: Deferred<Unit>) {
|
||||
val epoch = synchronized(controllerLock) { controllerEpoch }
|
||||
var current: Deferred<Unit>? = observed
|
||||
while (current != null) {
|
||||
current.join()
|
||||
current = synchronized(controllerLock) {
|
||||
if (controllerEpoch != epoch) return
|
||||
val next = running?.job
|
||||
if (next != null && next !== current) next else null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCurrentTailSync(generation: Long): Boolean =
|
||||
_state.value.syncGeneration == generation
|
||||
|
||||
private suspend fun runTailSync() {
|
||||
val generation = update { MessageWindowLogic.beginTailSync(it) }.syncGeneration
|
||||
try {
|
||||
val initial = _state.value
|
||||
val initialCursor = initial.newestPosition
|
||||
val preferLatestOnActivation = initial.preferLatestOnActivation
|
||||
val canIncrement = initialCursor != null
|
||||
&& initial.epoch != null
|
||||
&& !initial.requiresLatestReset
|
||||
&& !preferLatestOnActivation
|
||||
|
||||
if (!canIncrement) {
|
||||
val requestBaseline = baseline()
|
||||
val response = api.getMessages(sessionId, MessagesQuery.Latest(limit = PAGE_SIZE))
|
||||
if (!isCurrentTailSync(generation)) return
|
||||
update { previous ->
|
||||
if (previous.syncGeneration != generation) return@update previous
|
||||
MessageWindowLogic.applyLatestResponse(
|
||||
previous,
|
||||
response.windowMessages(),
|
||||
response.page,
|
||||
replaceServerRows = initial.requiresLatestReset
|
||||
|| preferLatestOnActivation
|
||||
|| response.page.reset,
|
||||
requestBaseline = requestBaseline,
|
||||
).copy(preferLatestOnActivation = false)
|
||||
}
|
||||
finishTailSync(generation, warning = null)
|
||||
return
|
||||
}
|
||||
|
||||
var after: MessagePosition = initialCursor!!
|
||||
var until: MessagePosition? = null
|
||||
while (true) {
|
||||
val requestBaseline = baseline()
|
||||
val response = api.getMessages(
|
||||
sessionId,
|
||||
MessagesQuery.After(
|
||||
afterAt = after.at,
|
||||
afterSeq = after.seq,
|
||||
untilAt = until?.at,
|
||||
untilSeq = until?.seq,
|
||||
epoch = initial.epoch!!,
|
||||
limit = PAGE_SIZE,
|
||||
),
|
||||
)
|
||||
if (!isCurrentTailSync(generation)) return
|
||||
|
||||
if (response.page.reset || response.page.direction == "latest") {
|
||||
update { previous ->
|
||||
if (previous.syncGeneration != generation) return@update previous
|
||||
MessageWindowLogic.applyLatestResponse(
|
||||
previous,
|
||||
response.windowMessages(),
|
||||
response.page,
|
||||
replaceServerRows = true,
|
||||
requestBaseline = requestBaseline,
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
val nextAfter = MessageWindowLogic.pagePosition(response.page.nextAfterAt, response.page.nextAfterSeq)
|
||||
val snapshotHead =
|
||||
MessageWindowLogic.pagePosition(response.page.snapshotHeadAt, response.page.snapshotHeadSeq)
|
||||
if (until == null) {
|
||||
until = snapshotHead
|
||||
}
|
||||
|
||||
update { previous ->
|
||||
if (previous.syncGeneration != generation) return@update previous
|
||||
MessageWindowLogic.applyAfterPage(previous, response.windowMessages(), response.page, nextAfter)
|
||||
}
|
||||
|
||||
val current = _state.value
|
||||
if (
|
||||
current.requiresLatestReset
|
||||
|| current.preferLatestOnActivation
|
||||
|| !response.page.hasMore
|
||||
|| nextAfter == null
|
||||
) {
|
||||
break
|
||||
}
|
||||
if (nextAfter <= after) {
|
||||
throw IllegalStateException("Message tail cursor did not advance")
|
||||
}
|
||||
after = nextAfter
|
||||
}
|
||||
|
||||
finishTailSync(generation, warning = null)
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Throwable) {
|
||||
if (!isCurrentTailSync(generation)) return
|
||||
finishTailSync(generation, error.message ?: "Failed to synchronize messages")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun finishTailSync(generation: Long, warning: String?) {
|
||||
update { MessageWindowLogic.finishTailSync(it, generation, warning) }
|
||||
persist()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- older pages --
|
||||
|
||||
/**
|
||||
* Load one older page (web `fetchOlderMessages`). On an epoch mismatch
|
||||
* the window is invalidated and a full tail sync (`ensureAfterCurrent`)
|
||||
* runs before the `stopped/epoch-reset` outcome is returned.
|
||||
* [onBeforeApply] runs while the state lock is held (mirror of the web's
|
||||
* synchronous updater) — it must not call back into this store.
|
||||
*/
|
||||
suspend fun fetchOlder(onBeforeApply: ((Long) -> Boolean)? = null): OlderLoadOutcome {
|
||||
val initial = _state.value
|
||||
when (val precheck = MessageWindowLogic.olderLoadPrecheck(initial)) {
|
||||
is MessageWindowLogic.OlderPrecheck.Stop -> return OlderLoadOutcome.Stopped(precheck.reason)
|
||||
is MessageWindowLogic.OlderPrecheck.Proceed -> {
|
||||
val before = precheck.before
|
||||
val generation = initial.olderGeneration + 1
|
||||
update { MessageWindowLogic.beginOlderLoad(it, generation) }
|
||||
return try {
|
||||
val response = api.getMessages(
|
||||
sessionId,
|
||||
MessagesQuery.Before(beforeAt = before.at, beforeSeq = before.seq, limit = PAGE_SIZE),
|
||||
)
|
||||
if (_state.value.olderGeneration != generation) {
|
||||
return OlderLoadOutcome.Stopped(OlderLoadOutcome.StopReason.Invalidated)
|
||||
}
|
||||
|
||||
if (initial.epoch != null && response.page.epoch != initial.epoch) {
|
||||
update { MessageWindowLogic.applyOlderEpochMismatch(it, generation) }
|
||||
syncTail(ensureAfterCurrent = true)
|
||||
return OlderLoadOutcome.Stopped(OlderLoadOutcome.StopReason.EpochReset)
|
||||
}
|
||||
|
||||
var historyVersion = 0L
|
||||
var addedRenderableCount = 0
|
||||
var applyRejected = false
|
||||
update { previous ->
|
||||
if (previous.olderGeneration != generation) return@update previous
|
||||
val incoming = response.windowMessages()
|
||||
addedRenderableCount = MessageWindowLogic.countNewRenderableMessages(previous, incoming)
|
||||
val nextHistoryVersion = previous.historyVersion + 1
|
||||
if (onBeforeApply != null && !onBeforeApply(nextHistoryVersion)) {
|
||||
applyRejected = true
|
||||
return@update MessageWindowLogic.rejectOlderApply(previous)
|
||||
}
|
||||
historyVersion = nextHistoryVersion
|
||||
MessageWindowLogic.applyOlderResponse(previous, incoming, response.page, nextHistoryVersion)
|
||||
}
|
||||
if (applyRejected || historyVersion == 0L) {
|
||||
return OlderLoadOutcome.Stopped(OlderLoadOutcome.StopReason.Invalidated)
|
||||
}
|
||||
persist()
|
||||
OlderLoadOutcome.Applied(
|
||||
historyVersion = historyVersion,
|
||||
hasMore = response.page.hasMore,
|
||||
addedRenderableCount = addedRenderableCount,
|
||||
)
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Throwable) {
|
||||
if (_state.value.olderGeneration != generation) {
|
||||
return OlderLoadOutcome.Stopped(OlderLoadOutcome.StopReason.Invalidated)
|
||||
}
|
||||
update {
|
||||
MessageWindowLogic.failOlderLoad(
|
||||
it,
|
||||
generation,
|
||||
error.message ?: "Failed to load older messages",
|
||||
)
|
||||
}
|
||||
OlderLoadOutcome.Failed(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun cancelOlderLoad() {
|
||||
update { MessageWindowLogic.cancelOlderLoad(it) }
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- SSE ingest --
|
||||
|
||||
/**
|
||||
* Route one message-stream SSE event into this window (the
|
||||
* `SyncTargets.onMessageEvent` hook). `messages-invalidated` clears the
|
||||
* window and starts a fresh tail sync; `scheduled-matured` re-syncs so
|
||||
* the released row (and its consumption) lands even if the
|
||||
* `message-received` frame was missed.
|
||||
*/
|
||||
suspend fun onMessageEvent(event: SyncEvent) {
|
||||
when (event) {
|
||||
is SyncEvent.MessageReceived -> ingestSseMessages(listOf(event.message.asWindowMessage()))
|
||||
is SyncEvent.MessagesConsumed -> markConsumed(event.localIds, event.invokedAt)
|
||||
is SyncEvent.MessageCancelled -> removeMessage(event.messageId)
|
||||
is SyncEvent.MessagesInvalidated -> {
|
||||
clear()
|
||||
scope.launch { syncTail() }
|
||||
}
|
||||
is SyncEvent.ScheduledMatured -> scope.launch { syncTail(ensureAfterCurrent = true) }
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
/** SSE `message-received` ingest (web `ingestIncomingMessages`). */
|
||||
suspend fun ingestSseMessages(messages: List<WindowMessage>) {
|
||||
if (messages.isEmpty()) return
|
||||
update { MessageWindowLogic.ingestIncoming(it, messages) }
|
||||
persist()
|
||||
}
|
||||
|
||||
/** SSE `messages-consumed` (web `markMessagesConsumed`). */
|
||||
suspend fun markConsumed(localIds: List<String>, invokedAt: Long) {
|
||||
if (localIds.isEmpty()) return
|
||||
update { MessageWindowLogic.markConsumed(it, localIds, invokedAt) }
|
||||
persist()
|
||||
}
|
||||
|
||||
/** SSE `message-cancelled` / optimistic DELETE removal (web `removeOptimisticMessage`). */
|
||||
suspend fun removeMessage(localIdOrId: String) {
|
||||
update { MessageWindowLogic.removeByLocalIdOrId(it, localIdOrId) }
|
||||
persist()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ optimistic sends --
|
||||
|
||||
/** Append a pre-built optimistic row (web `appendOptimisticMessage`). */
|
||||
suspend fun appendOptimistic(message: WindowMessage) {
|
||||
update { MessageWindowLogic.appendOptimistic(it, message) }
|
||||
persist()
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the standard optimistic row for a send
|
||||
* (`useSendMessage.createOptimisticMessage`): status `sending` until the
|
||||
* POST settles, then [updateStatus] to `queued`/`sent`/`failed`.
|
||||
*/
|
||||
suspend fun appendOptimistic(
|
||||
localId: String,
|
||||
text: String,
|
||||
attachments: List<AttachmentMetadata>? = null,
|
||||
scheduledAt: Long? = null,
|
||||
deliveryMode: String = "queue",
|
||||
createdAt: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
appendOptimistic(
|
||||
buildOptimisticMessage(
|
||||
localId = localId,
|
||||
text = text,
|
||||
createdAt = createdAt,
|
||||
attachments = attachments,
|
||||
scheduledAt = scheduledAt,
|
||||
deliveryMode = deliveryMode,
|
||||
status = MessageStatus.Sending,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun updateStatus(localId: String, status: MessageStatus) {
|
||||
update { MessageWindowLogic.updateStatus(it, localId, status) }
|
||||
persist()
|
||||
}
|
||||
|
||||
/**
|
||||
* A cancel DELETE answered `{"status":"invoked"}` — too late, the agent
|
||||
* consumed the row. Remove the queued snapshot and ingest the returned
|
||||
* authoritative row as `sent` (web `useCancelQueuedMessage`).
|
||||
*/
|
||||
suspend fun applyCancelInvoked(localId: String, message: WindowMessage) {
|
||||
removeMessage(localId)
|
||||
appendOptimistic(message.copy(status = MessageStatus.Sent))
|
||||
}
|
||||
|
||||
// ------------------------------------------------- queued reconciliation --
|
||||
|
||||
fun queuedReconcileCandidateLocalIds(): List<String> =
|
||||
MessageWindowLogic.queuedReconcileCandidateLocalIds(_state.value)
|
||||
|
||||
suspend fun reconcileQueuedLocalIds(candidateLocalIds: List<String>, queuedLocalIds: List<String>) {
|
||||
update { MessageWindowLogic.reconcileQueuedLocalIds(it, candidateLocalIds, queuedLocalIds) }
|
||||
persist()
|
||||
}
|
||||
|
||||
/**
|
||||
* Queued-state recovery after a `resume: 'gap'` reconnect (port of
|
||||
* `web/src/lib/queued-state-reconciliation.ts`): tail-sync to the drain,
|
||||
* collect candidates, ask the hub for the verdict in ≤1000-id batches,
|
||||
* stamp invoked rows like `messages-consumed`, drop deleted candidates.
|
||||
*/
|
||||
suspend fun reconcileQueuedState() {
|
||||
syncTail(ensureAfterCurrent = true)
|
||||
val candidateLocalIds = queuedReconcileCandidateLocalIds()
|
||||
if (candidateLocalIds.isEmpty()) return
|
||||
val queuedLocalIds = mutableListOf<String>()
|
||||
val invokedLocalMessages = mutableListOf<Pair<String, Long>>()
|
||||
candidateLocalIds.chunked(QUEUED_STATE_BATCH_SIZE).forEach { batch ->
|
||||
val response = api.getQueuedState(sessionId, batch)
|
||||
queuedLocalIds += response.queuedLocalIds
|
||||
invokedLocalMessages += response.invokedLocalMessages.map { it.localId to it.invokedAt }
|
||||
}
|
||||
val invokedByTimestamp = LinkedHashMap<Long, MutableList<String>>()
|
||||
for ((localId, invokedAt) in invokedLocalMessages) {
|
||||
invokedByTimestamp.getOrPut(invokedAt) { mutableListOf() }.add(localId)
|
||||
}
|
||||
for ((invokedAt, localIds) in invokedByTimestamp) {
|
||||
markConsumed(localIds, invokedAt)
|
||||
}
|
||||
reconcileQueuedLocalIds(candidateLocalIds, queuedLocalIds)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ lifecycle --
|
||||
|
||||
/** Switch view mode (web `setMessageViewMode`); tail re-entry trims. */
|
||||
suspend fun setViewMode(mode: MessageViewMode) {
|
||||
update { MessageWindowLogic.setViewMode(it, mode) }
|
||||
persist()
|
||||
}
|
||||
|
||||
/**
|
||||
* Session (re-)activation (web `activateMessageWindow`): trims back to
|
||||
* the visible window and, with usable persisted state, requests a
|
||||
* fresh-latest trailing run from an in-flight sync.
|
||||
*/
|
||||
suspend fun activate() {
|
||||
var requestedLatest = false
|
||||
update { previous ->
|
||||
val activation = MessageWindowLogic.activate(previous)
|
||||
requestedLatest = activation.requestedLatest
|
||||
activation.state
|
||||
}
|
||||
if (requestedLatest) {
|
||||
synchronized(controllerLock) {
|
||||
if (running != null) {
|
||||
trailingRequested = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Web `clearMessageWindow`: forget everything but keep generations poisoned. */
|
||||
suspend fun clear() {
|
||||
synchronized(controllerLock) {
|
||||
controllerEpoch += 1
|
||||
running = null
|
||||
trailingRequested = false
|
||||
}
|
||||
snapshots?.delete(sessionId)
|
||||
update { previous ->
|
||||
MessageWindowLogic.createState(sessionId).copy(
|
||||
syncGeneration = previous.syncGeneration + 1,
|
||||
olderGeneration = previous.olderGeneration + 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed this window from another session's (web
|
||||
* `seedMessageWindowFromSession`) — resume/reopen can hand back a new
|
||||
* session id; the old rows render instantly and `requiresLatestReset`
|
||||
* forces a fresh latest page underneath.
|
||||
*/
|
||||
suspend fun seedFrom(source: MessageWindowStore) {
|
||||
if (source.sessionId == sessionId) return
|
||||
synchronized(controllerLock) {
|
||||
controllerEpoch += 1
|
||||
running = null
|
||||
trailingRequested = false
|
||||
}
|
||||
val sourceState = source.state.value
|
||||
update { target -> MessageWindowLogic.seededState(sourceState, target) }
|
||||
persist()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- internals --
|
||||
|
||||
private suspend fun update(transform: (MessageWindowState) -> MessageWindowState): MessageWindowState =
|
||||
stateMutex.withLock {
|
||||
val next = transform(_state.value)
|
||||
_state.value = next
|
||||
next
|
||||
}
|
||||
|
||||
/** Identity baseline of the current rows, keyed by id (web `requestBaseline`). */
|
||||
private fun baseline(): Map<String, WindowMessage> =
|
||||
_state.value.messages.associateBy { it.id }
|
||||
|
||||
private fun MessagesResponse.windowMessages(): List<WindowMessage> =
|
||||
messages.map { it.asWindowMessage() }
|
||||
|
||||
private suspend fun persist() {
|
||||
val snapshotStore = snapshots ?: return
|
||||
val state = _state.value
|
||||
if (MessageWindowLogic.shouldPersist(state)) {
|
||||
snapshotStore.save(sessionId, MessageWindowLogic.toPersisted(state))
|
||||
} else {
|
||||
snapshotStore.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val QUEUED_STATE_BATCH_SIZE = 1000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package app.hapi.data.store
|
||||
|
||||
import app.hapi.data.api.MessagesApi
|
||||
import app.hapi.protocol.window.MessageWindowLogic
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* Per-hub registry of per-session [MessageWindowStore]s — the analogue of the
|
||||
* web module's session-keyed maps. [open] hydrates a cold session from its
|
||||
* disk snapshot (interrupted `sending` rows restore, stale snapshots start
|
||||
* flagged for a latest reset); [seed] carries a window across a
|
||||
* resume/reopen id change. Snapshot files themselves are LRU-capped by
|
||||
* [WindowSnapshots].
|
||||
*/
|
||||
class MessageWindowStores(
|
||||
private val api: MessagesApi,
|
||||
private val scope: CoroutineScope,
|
||||
private val snapshots: WindowSnapshots? = null,
|
||||
) {
|
||||
private val mutex = Mutex()
|
||||
private val stores = HashMap<String, MessageWindowStore>()
|
||||
|
||||
/** The session's store, hydrating from its snapshot on first open. */
|
||||
suspend fun open(sessionId: String): MessageWindowStore {
|
||||
mutex.withLock { stores[sessionId] }?.let { return it }
|
||||
// Hydrate outside the lock (disk I/O), then race-tolerantly install.
|
||||
val hydrated = snapshots?.load(sessionId)?.let { MessageWindowLogic.hydrate(sessionId, it) }
|
||||
return mutex.withLock {
|
||||
stores.getOrPut(sessionId) {
|
||||
MessageWindowStore(
|
||||
sessionId = sessionId,
|
||||
api = api,
|
||||
scope = scope,
|
||||
snapshots = snapshots,
|
||||
initialState = hydrated,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Already-open store, if any (no hydration). */
|
||||
suspend fun peek(sessionId: String): MessageWindowStore? =
|
||||
mutex.withLock { stores[sessionId] }
|
||||
|
||||
/** Web `seedMessageWindowFromSession` for resume/reopen id migration. */
|
||||
suspend fun seed(fromSessionId: String, toSessionId: String) {
|
||||
if (fromSessionId.isEmpty() || toSessionId.isEmpty() || fromSessionId == toSessionId) return
|
||||
val source = open(fromSessionId)
|
||||
val target = open(toSessionId)
|
||||
target.seedFrom(source)
|
||||
}
|
||||
|
||||
/** Web `clearMessageWindow` (e.g. on `session-removed`). */
|
||||
suspend fun clear(sessionId: String) {
|
||||
mutex.withLock { stores[sessionId] }?.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package app.hapi.data.store
|
||||
|
||||
import app.hapi.protocol.window.PersistedMessageWindow
|
||||
import app.hapi.protocol.wire.HapiJson
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.security.MessageDigest
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Per-session message-window snapshots on disk: one JSON file per session in
|
||||
* [dir], written atomically (temp file + fsync + rename — the same guarantee
|
||||
* `android.util.AtomicFile` provides, hand-rolled here so plain JVM unit
|
||||
* tests can exercise it), pruned LRU to [maxSessions] files.
|
||||
*
|
||||
* Mirrors the web's `sessionStorage` persistence of
|
||||
* `PersistedMessageWindowState` (key `hapi:message-window:v2:`): messages +
|
||||
* cursors + epoch for instant cold-start rendering; `hydrate` in
|
||||
* `MessageWindowLogic` restores interrupted send states and flags stale
|
||||
* snapshots for a latest reset.
|
||||
*
|
||||
* TODO(B-M2b dedup): fold into the shared `JsonSnapshotStore` once that
|
||||
* lands — this class predates it in a parallel work package.
|
||||
*/
|
||||
class WindowSnapshots(
|
||||
private val dir: File,
|
||||
private val maxSessions: Int = DEFAULT_MAX_SESSIONS,
|
||||
private val json: Json = HapiJson,
|
||||
private val io: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
suspend fun save(sessionId: String, snapshot: PersistedMessageWindow): Unit = withContext(io) {
|
||||
dir.mkdirs()
|
||||
val target = fileFor(sessionId)
|
||||
val temp = File(dir, target.name + ".tmp")
|
||||
FileOutputStream(temp).use { stream ->
|
||||
stream.write(json.encodeToString(PersistedMessageWindow.serializer(), snapshot).toByteArray())
|
||||
stream.fd.sync()
|
||||
}
|
||||
if (!temp.renameTo(target)) {
|
||||
// Windows-style rename-over-existing failure; best effort swap.
|
||||
target.delete()
|
||||
temp.renameTo(target)
|
||||
}
|
||||
prune()
|
||||
}
|
||||
|
||||
suspend fun load(sessionId: String): PersistedMessageWindow? = withContext(io) {
|
||||
val file = fileFor(sessionId)
|
||||
if (!file.isFile) return@withContext null
|
||||
try {
|
||||
val snapshot = json.decodeFromString(PersistedMessageWindow.serializer(), file.readText())
|
||||
// Touch for LRU recency: reading a snapshot marks the session live.
|
||||
file.setLastModified(System.currentTimeMillis())
|
||||
snapshot
|
||||
} catch (_: Exception) {
|
||||
file.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun delete(sessionId: String): Unit = withContext(io) {
|
||||
fileFor(sessionId).delete()
|
||||
Unit
|
||||
}
|
||||
|
||||
/** Drop the least-recently-written files beyond [maxSessions]. */
|
||||
private fun prune() {
|
||||
val files = dir.listFiles { file -> file.isFile && file.name.endsWith(SUFFIX) } ?: return
|
||||
if (files.size <= maxSessions) return
|
||||
files.sortedByDescending { it.lastModified() }
|
||||
.drop(maxSessions)
|
||||
.forEach { it.delete() }
|
||||
}
|
||||
|
||||
/** Session ids are arbitrary strings — file names come from a digest. */
|
||||
private fun fileFor(sessionId: String): File {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(sessionId.toByteArray())
|
||||
val name = digest.joinToString("") { "%02x".format(it) }.take(32)
|
||||
return File(dir, name + SUFFIX)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_MAX_SESSIONS = 10
|
||||
const val SUFFIX = ".window.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package app.hapi.data.store
|
||||
|
||||
import app.hapi.data.api.MessagesApi
|
||||
import app.hapi.data.api.MessagesQuery
|
||||
import app.hapi.protocol.window.MessageStatus
|
||||
import app.hapi.protocol.window.MessageWindowLogic
|
||||
import app.hapi.protocol.window.PersistedMessageWindow
|
||||
import app.hapi.protocol.window.asWindowMessage
|
||||
import app.hapi.protocol.window.buildOptimisticMessage
|
||||
import app.hapi.protocol.wire.DecryptedMessage
|
||||
import app.hapi.protocol.wire.MessagesPage
|
||||
import app.hapi.protocol.wire.MessagesResponse
|
||||
import app.hapi.protocol.wire.OptionalField
|
||||
import app.hapi.protocol.wire.QueuedStateResponse
|
||||
import java.io.File
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
|
||||
/**
|
||||
* Targeted coverage for what the pagination fixtures cannot reach: tail-sync
|
||||
* single-flight/trailing behavior, concurrent-ingest preservation across a
|
||||
* reset replace (identity baseline), the cursor-no-advance guard, snapshot
|
||||
* round-trips + LRU, seed-on-resume, and Mutex discipline under parallelism.
|
||||
*/
|
||||
class MessageWindowStoreTest {
|
||||
|
||||
@get:Rule
|
||||
val temp = TemporaryFolder()
|
||||
|
||||
// ------------------------------------------------------------- fixtures --
|
||||
|
||||
private fun agentRow(id: String, seq: Long, at: Long): DecryptedMessage = DecryptedMessage(
|
||||
id = id,
|
||||
seq = seq,
|
||||
createdAt = at,
|
||||
invokedAt = OptionalField.Present(at),
|
||||
content = buildJsonObject {
|
||||
put("role", "agent")
|
||||
put("content", buildJsonObject {
|
||||
put("type", "codex")
|
||||
put("data", buildJsonObject {
|
||||
put("type", "message")
|
||||
put("message", id)
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
private fun latestPage(
|
||||
messages: List<DecryptedMessage>,
|
||||
epoch: Long,
|
||||
reset: Boolean = false,
|
||||
hasMore: Boolean = false,
|
||||
): MessagesResponse {
|
||||
val newest = messages.lastOrNull()
|
||||
val oldest = messages.firstOrNull()
|
||||
return MessagesResponse(
|
||||
messages = messages,
|
||||
page = MessagesPage(
|
||||
direction = "latest",
|
||||
limit = 200,
|
||||
epoch = epoch,
|
||||
reset = reset,
|
||||
nextBeforeSeq = oldest?.seq,
|
||||
nextBeforeAt = oldest?.positionAt,
|
||||
nextAfterSeq = null,
|
||||
nextAfterAt = null,
|
||||
snapshotHeadSeq = newest?.seq,
|
||||
snapshotHeadAt = newest?.positionAt,
|
||||
hasMore = hasMore,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun afterPage(
|
||||
messages: List<DecryptedMessage>,
|
||||
epoch: Long,
|
||||
nextAfter: Pair<Long, Long>?,
|
||||
hasMore: Boolean,
|
||||
): MessagesResponse = MessagesResponse(
|
||||
messages = messages,
|
||||
page = MessagesPage(
|
||||
direction = "after",
|
||||
limit = 200,
|
||||
epoch = epoch,
|
||||
reset = false,
|
||||
nextBeforeSeq = null,
|
||||
nextBeforeAt = null,
|
||||
nextAfterSeq = nextAfter?.second,
|
||||
nextAfterAt = nextAfter?.first,
|
||||
snapshotHeadSeq = nextAfter?.second,
|
||||
snapshotHeadAt = nextAfter?.first,
|
||||
hasMore = hasMore,
|
||||
),
|
||||
)
|
||||
|
||||
/** Api whose responses are released manually, so tests control interleaving. */
|
||||
private class GatedMessagesApi : MessagesApi {
|
||||
val requests = mutableListOf<MessagesQuery>()
|
||||
private val responses = Channel<MessagesResponse>(Channel.UNLIMITED)
|
||||
|
||||
fun release(response: MessagesResponse) {
|
||||
check(responses.trySend(response).isSuccess)
|
||||
}
|
||||
|
||||
override suspend fun getMessages(sessionId: String, query: MessagesQuery): MessagesResponse {
|
||||
requests += query
|
||||
return responses.receive()
|
||||
}
|
||||
|
||||
override suspend fun getQueuedState(sessionId: String, localIds: List<String>): QueuedStateResponse =
|
||||
QueuedStateResponse(emptyList(), emptyList())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ tail sync --
|
||||
|
||||
@Test
|
||||
fun `concurrent syncTail calls coalesce into a single run`() = runTest {
|
||||
val api = GatedMessagesApi()
|
||||
val store = MessageWindowStore("s", api, backgroundScope)
|
||||
|
||||
val first = launch { store.syncTail() }
|
||||
runCurrent()
|
||||
assertEquals(1, api.requests.size)
|
||||
|
||||
val second = launch { store.syncTail() }
|
||||
runCurrent()
|
||||
// The second caller joined the in-flight run instead of issuing its own.
|
||||
assertEquals(1, api.requests.size)
|
||||
|
||||
api.release(latestPage(listOf(agentRow("a-1", 1, 1000)), epoch = 0))
|
||||
first.join()
|
||||
second.join()
|
||||
assertEquals(1, api.requests.size)
|
||||
assertEquals(listOf("a-1"), store.state.value.messages.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ensureAfterCurrent requests a trailing run and drains it`() = runTest {
|
||||
val api = GatedMessagesApi()
|
||||
val store = MessageWindowStore("s", api, backgroundScope)
|
||||
|
||||
val first = launch { store.syncTail() }
|
||||
runCurrent()
|
||||
assertEquals(1, api.requests.size)
|
||||
|
||||
val drain = launch { store.syncTail(ensureAfterCurrent = true) }
|
||||
runCurrent()
|
||||
assertEquals(1, api.requests.size)
|
||||
|
||||
api.release(latestPage(listOf(agentRow("a-1", 1, 1000)), epoch = 0))
|
||||
first.join()
|
||||
runCurrent()
|
||||
// The trailing run started and issued its own (after-cursor) request.
|
||||
assertEquals(2, api.requests.size)
|
||||
assertTrue(api.requests[1] is MessagesQuery.After)
|
||||
assertTrue(drain.isActive)
|
||||
|
||||
api.release(afterPage(emptyList(), epoch = 0, nextAfter = 1000L to 1L, hasMore = false))
|
||||
drain.join()
|
||||
assertEquals(2, api.requests.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset replace preserves rows that arrived while the request was in flight`() = runTest {
|
||||
val api = GatedMessagesApi()
|
||||
val store = MessageWindowStore("s", api, backgroundScope)
|
||||
|
||||
// Seed epoch + cursor with a completed latest sync.
|
||||
val seed = launch { store.syncTail() }
|
||||
runCurrent()
|
||||
api.release(latestPage(listOf(agentRow("a-1", 1, 1000)), epoch = 0))
|
||||
seed.join()
|
||||
|
||||
// Second sync goes down the after-cursor path and hangs on the api.
|
||||
val sync = launch { store.syncTail() }
|
||||
runCurrent()
|
||||
assertTrue(api.requests.last() is MessagesQuery.After)
|
||||
|
||||
// A live SSE row lands while the request is in flight.
|
||||
store.ingestSseMessages(listOf(agentRow("b-2", 2, 2000).asWindowMessage()))
|
||||
|
||||
// The server answers with a reset page that does not contain b-2.
|
||||
api.release(latestPage(listOf(agentRow("c-3", 3, 3000)), epoch = 1, reset = true))
|
||||
sync.join()
|
||||
|
||||
val state = store.state.value
|
||||
// a-1 (captured in the request baseline) was replaced; the concurrent
|
||||
// b-2 and the authoritative c-3 both survive.
|
||||
assertEquals(listOf("b-2", "c-3"), state.messages.map { it.id })
|
||||
assertEquals(1L, state.epoch)
|
||||
assertEquals(3000L to 3L, state.newestPosition!!.let { it.at to it.seq })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tail cursor that does not advance aborts with a warning instead of spinning`() = runTest {
|
||||
val api = GatedMessagesApi()
|
||||
val store = MessageWindowStore("s", api, backgroundScope)
|
||||
|
||||
val seed = launch { store.syncTail() }
|
||||
runCurrent()
|
||||
api.release(latestPage(listOf(agentRow("a-1", 1, 1000)), epoch = 0))
|
||||
seed.join()
|
||||
|
||||
val sync = launch { store.syncTail() }
|
||||
runCurrent()
|
||||
// hasMore with a non-advancing nextAfter is a protocol violation.
|
||||
api.release(afterPage(listOf(agentRow("b-2", 2, 2000)), epoch = 0, nextAfter = 1000L to 1L, hasMore = true))
|
||||
sync.join()
|
||||
|
||||
val state = store.state.value
|
||||
assertEquals("Message tail cursor did not advance", state.warning)
|
||||
assertTrue(!state.isSyncingTail)
|
||||
assertEquals(2, api.requests.size)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ snapshots --
|
||||
|
||||
@Test
|
||||
fun `snapshot round-trip restores interrupted sends and flags stale snapshots`() = runBlocking {
|
||||
val snapshots = WindowSnapshots(temp.newFolder(), io = Dispatchers.Unconfined)
|
||||
|
||||
val queuedSending = buildOptimisticMessage(
|
||||
localId = "local-1",
|
||||
text = "hello",
|
||||
createdAt = 1000,
|
||||
status = MessageStatus.Sending,
|
||||
)
|
||||
val fresh = PersistedMessageWindow(
|
||||
messages = listOf(queuedSending, agentRow("a-1", 1, 500).asWindowMessage()),
|
||||
hasMore = true,
|
||||
oldestPositionAt = 500,
|
||||
oldestPositionSeq = 1,
|
||||
newestPositionAt = 500,
|
||||
newestPositionSeq = 1,
|
||||
epoch = 3,
|
||||
)
|
||||
snapshots.save("session-a", fresh)
|
||||
|
||||
val loaded = snapshots.load("session-a")!!
|
||||
val hydrated = MessageWindowLogic.hydrate("session-a", loaded)
|
||||
// Interrupted `sending` on a queued row restores to `queued`.
|
||||
assertEquals(
|
||||
MessageStatus.Queued,
|
||||
hydrated.messages.first { it.id == "local-1" }.status,
|
||||
)
|
||||
assertEquals(3L, hydrated.epoch)
|
||||
assertTrue(!hydrated.requiresLatestReset)
|
||||
assertEquals(500L to 1L, hydrated.newestPosition!!.let { it.at to it.seq })
|
||||
|
||||
// A snapshot without a usable epoch hydrates flagged for a latest reset.
|
||||
val stale = fresh.copy(epoch = null)
|
||||
snapshots.save("session-b", stale)
|
||||
val staleHydrated = MessageWindowLogic.hydrate("session-b", snapshots.load("session-b")!!)
|
||||
assertTrue(staleHydrated.requiresLatestReset)
|
||||
assertNull(staleHydrated.epoch)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `snapshots are LRU-capped at ten sessions`() = runBlocking {
|
||||
val dir = temp.newFolder()
|
||||
val snapshots = WindowSnapshots(dir, io = Dispatchers.Unconfined)
|
||||
repeat(12) { index ->
|
||||
snapshots.save(
|
||||
"session-$index",
|
||||
PersistedMessageWindow(messages = listOf(agentRow("a-$index", 1, 1000).asWindowMessage())),
|
||||
)
|
||||
}
|
||||
val files = dir.listFiles { file: File -> file.name.endsWith(".window.json") }.orEmpty()
|
||||
assertEquals(10, files.size)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- seed --
|
||||
|
||||
@Test
|
||||
fun `seedFrom carries rows and older cursor but forces a latest reset`() = runTest {
|
||||
val api = GatedMessagesApi()
|
||||
val source = MessageWindowStore("old-session", api, backgroundScope)
|
||||
val seed = launch { source.syncTail() }
|
||||
runCurrent()
|
||||
api.release(latestPage(listOf(agentRow("a-1", 1, 1000)), epoch = 5, hasMore = true))
|
||||
seed.join()
|
||||
|
||||
val target = MessageWindowStore("new-session", api, backgroundScope)
|
||||
target.seedFrom(source)
|
||||
|
||||
val state = target.state.value
|
||||
assertEquals("new-session", state.sessionId)
|
||||
assertEquals(listOf("a-1"), state.messages.map { it.id })
|
||||
assertTrue(state.hasMore)
|
||||
assertTrue(state.requiresLatestReset)
|
||||
assertNull(state.epoch)
|
||||
assertNull(state.newestPosition)
|
||||
assertEquals(1000L to 1L, state.oldestPosition!!.let { it.at to it.seq })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ mutex discipline --
|
||||
|
||||
@Test
|
||||
fun `parallel mutations from real threads serialize through the state mutex`() = runBlocking {
|
||||
val api = GatedMessagesApi()
|
||||
val store = MessageWindowStore("s", api, this)
|
||||
withContext(Dispatchers.Default) {
|
||||
(0 until 64).map { index ->
|
||||
launch {
|
||||
store.appendOptimistic(
|
||||
localId = "local-$index",
|
||||
text = "message $index",
|
||||
createdAt = 1000L + index,
|
||||
)
|
||||
store.updateStatus("local-$index", MessageStatus.Queued)
|
||||
}
|
||||
}.forEach { it.join() }
|
||||
}
|
||||
val state = store.state.value
|
||||
assertEquals(64, state.messages.size)
|
||||
assertTrue(state.messages.all { it.status == MessageStatus.Queued && it.isQueuedForInvocation })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package app.hapi.data.store
|
||||
|
||||
import app.hapi.data.api.MessagesApi
|
||||
import app.hapi.data.api.MessagesQuery
|
||||
import app.hapi.protocol.window.MessageStatus
|
||||
import app.hapi.protocol.window.MessageViewMode
|
||||
import app.hapi.protocol.window.MessageWindowState
|
||||
import app.hapi.protocol.window.OlderLoadOutcome
|
||||
import app.hapi.protocol.window.WindowMessage
|
||||
import app.hapi.protocol.wire.HapiJson
|
||||
import app.hapi.protocol.wire.MessagesResponse
|
||||
import app.hapi.protocol.wire.OptionalField
|
||||
import app.hapi.protocol.wire.QueuedStateResponse
|
||||
import app.hapi.protocol.wire.longOrNull
|
||||
import app.hapi.protocol.wire.stringOrNull
|
||||
import java.io.File
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.fail
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.Parameterized
|
||||
|
||||
/**
|
||||
* Pagination conformance suite: replays every op script in
|
||||
* `shared/fixtures/pagination/` against the REAL [MessageWindowStore]
|
||||
* (driven by a scripted [MessagesApi]) and requires
|
||||
*
|
||||
* - the exact same `GET /messages` requests, in order (`expectedRequests`),
|
||||
* - the same older-load outcomes (`expectedOutcome`) and queued-state
|
||||
* reconcile candidates (`expectedCandidates`),
|
||||
* - the exact final window projection (`expectedState`).
|
||||
*
|
||||
* The twin of `web/src/lib/message-window-store.fixtures.test.ts` /
|
||||
* `web/scripts/fixtures/pagination/runner.ts`. A failure here means this port
|
||||
* drifted from the web reference (or the fixtures were regenerated after a
|
||||
* web behavior change and the port must catch up).
|
||||
*
|
||||
* Determinism: ops run strictly sequentially; the store's scope uses
|
||||
* [Dispatchers.Unconfined], so internal tail-sync jobs run to completion
|
||||
* within each op (the scripted api never suspends) — mirroring the web
|
||||
* harness awaiting every op.
|
||||
*/
|
||||
@RunWith(Parameterized::class)
|
||||
class PaginationFixtureTest(private val fixtureName: String) {
|
||||
|
||||
companion object {
|
||||
private val fixturesDir: File by lazy {
|
||||
val path = checkNotNull(System.getProperty("hapi.fixtures.dir")) {
|
||||
"hapi.fixtures.dir system property not set (see core/data/build.gradle.kts)"
|
||||
}
|
||||
File(path).also {
|
||||
check(it.isDirectory) { "fixtures dir does not exist: $it" }
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Parameterized.Parameters(name = "{0}")
|
||||
fun fixtures(): List<String> {
|
||||
val files = File(fixturesDir, "pagination")
|
||||
.listFiles { file -> file.name.endsWith(".json") }
|
||||
?.map { it.name }
|
||||
?.sorted()
|
||||
.orEmpty()
|
||||
check(files.isNotEmpty()) {
|
||||
"no pagination fixtures found under $fixturesDir/pagination — refusing to pass on zero files"
|
||||
}
|
||||
return files
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replaying the ops against the real store matches the stored expectations`() {
|
||||
val document = HapiJson.parseToJsonElement(
|
||||
File(File(fixturesDir, "pagination"), fixtureName).readText()
|
||||
).jsonObject
|
||||
|
||||
val version = File(fixturesDir, "VERSION").readText().trim().toInt()
|
||||
assertEquals(version, document.getValue("fixtureVersion").longOrNull?.toInt(), "fixtureVersion")
|
||||
assertEquals(fixtureName, "${document.getValue("name").stringOrNull}.json", "name matches file")
|
||||
|
||||
val scripted = ScriptedMessagesApi()
|
||||
val store = MessageWindowStore(
|
||||
sessionId = "fixture-pagination-${document.getValue("name").stringOrNull}",
|
||||
api = scripted,
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
|
||||
)
|
||||
|
||||
runBlocking {
|
||||
for ((opIndex, opElement) in document.getValue("ops").jsonArray.withIndex()) {
|
||||
executeOp(store, scripted, opElement.jsonObject, opIndex)
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
document.getValue("expectedState"),
|
||||
projectWindowState(store.state.value),
|
||||
"final window projection ($fixtureName)",
|
||||
)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ op replay --
|
||||
|
||||
private suspend fun executeOp(
|
||||
store: MessageWindowStore,
|
||||
scripted: ScriptedMessagesApi,
|
||||
op: JsonObject,
|
||||
opIndex: Int,
|
||||
) {
|
||||
val kind = op.getValue("op").stringOrNull
|
||||
when (kind) {
|
||||
"sync-tail" -> {
|
||||
scripted.begin(decodeResponses(op))
|
||||
store.syncTail()
|
||||
assertSettled(store, scripted, opIndex)
|
||||
assertRequests(op, scripted, opIndex)
|
||||
}
|
||||
"fetch-older" -> {
|
||||
scripted.begin(decodeResponses(op))
|
||||
val outcome = store.fetchOlder()
|
||||
assertSettled(store, scripted, opIndex)
|
||||
assertRequests(op, scripted, opIndex)
|
||||
op["expectedOutcome"]?.let { expected ->
|
||||
assertEquals(expected, projectOutcome(outcome), "ops[$opIndex] outcome")
|
||||
}
|
||||
}
|
||||
"sse-messages" -> store.ingestSseMessages(
|
||||
op.getValue("messages").jsonArray.map(::decodeWindowMessage)
|
||||
)
|
||||
"append-optimistic" -> store.appendOptimistic(decodeWindowMessage(op.getValue("message")))
|
||||
"update-status" -> store.updateStatus(
|
||||
localId = op.getValue("localId").stringOrNull!!,
|
||||
status = MessageStatus.fromWire(op.getValue("status").stringOrNull)!!,
|
||||
)
|
||||
"messages-consumed" -> store.markConsumed(
|
||||
localIds = op.getValue("localIds").jsonArray.map { it.stringOrNull!! },
|
||||
invokedAt = op.getValue("invokedAt").longOrNull!!,
|
||||
)
|
||||
"message-cancelled" -> store.removeMessage(op.getValue("localId").stringOrNull!!)
|
||||
"cancel-invoked" -> store.applyCancelInvoked(
|
||||
localId = op.getValue("localId").stringOrNull!!,
|
||||
message = decodeWindowMessage(op.getValue("message")),
|
||||
)
|
||||
"set-view-mode" -> store.setViewMode(
|
||||
when (op.getValue("mode").stringOrNull) {
|
||||
"history" -> MessageViewMode.History
|
||||
else -> MessageViewMode.Tail
|
||||
}
|
||||
)
|
||||
"queued-state" -> {
|
||||
// Mirrors the web runner (reconcileQueuedStateAfterConnect's
|
||||
// post tail-sync half): collect candidates, apply invoked
|
||||
// verdicts grouped by timestamp, drop deleted candidates.
|
||||
val candidates = store.queuedReconcileCandidateLocalIds()
|
||||
op["expectedCandidates"]?.let { expected ->
|
||||
assertEquals(
|
||||
expected,
|
||||
JsonArray(candidates.map(::JsonPrimitive)),
|
||||
"ops[$opIndex] candidates",
|
||||
)
|
||||
}
|
||||
val invokedByTimestamp = LinkedHashMap<Long, MutableList<String>>()
|
||||
for (entry in op.getValue("invoked").jsonArray) {
|
||||
val invoked = entry.jsonObject
|
||||
invokedByTimestamp
|
||||
.getOrPut(invoked.getValue("invokedAt").longOrNull!!) { mutableListOf() }
|
||||
.add(invoked.getValue("localId").stringOrNull!!)
|
||||
}
|
||||
for ((invokedAt, localIds) in invokedByTimestamp) {
|
||||
store.markConsumed(localIds, invokedAt)
|
||||
}
|
||||
store.reconcileQueuedLocalIds(
|
||||
candidateLocalIds = candidates,
|
||||
queuedLocalIds = op.getValue("queuedLocalIds").jsonArray.map { it.stringOrNull!! },
|
||||
)
|
||||
}
|
||||
else -> fail("ops[$opIndex]: unknown op '$kind'")
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeResponses(op: JsonObject): List<MessagesResponse> =
|
||||
HapiJson.decodeFromJsonElement(
|
||||
ListSerializer(MessagesResponse.serializer()),
|
||||
op.getValue("responses"),
|
||||
)
|
||||
|
||||
private fun decodeWindowMessage(element: JsonElement): WindowMessage =
|
||||
HapiJson.decodeFromJsonElement(WindowMessage.serializer(), element)
|
||||
|
||||
private fun assertSettled(store: MessageWindowStore, scripted: ScriptedMessagesApi, opIndex: Int) {
|
||||
assertEquals(0, scripted.remaining, "ops[$opIndex]: scripted response(s) left unconsumed")
|
||||
val state = store.state.value
|
||||
assertNull(state.warning, "ops[$opIndex]: store reported a warning")
|
||||
assertTrue(!state.isSyncingTail && !state.isLoadingMore, "ops[$opIndex]: store still busy after the op settled")
|
||||
}
|
||||
|
||||
private fun assertRequests(op: JsonObject, scripted: ScriptedMessagesApi, opIndex: Int) {
|
||||
val expected = op["expectedRequests"] ?: return
|
||||
assertEquals(expected, JsonArray(scripted.requests.toList()), "ops[$opIndex] requests")
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- projection --
|
||||
|
||||
private fun projectOutcome(outcome: OlderLoadOutcome): JsonObject = when (outcome) {
|
||||
is OlderLoadOutcome.Applied -> buildJsonObject {
|
||||
put("kind", "applied")
|
||||
put("hasMore", outcome.hasMore)
|
||||
put("addedRenderableCount", outcome.addedRenderableCount)
|
||||
}
|
||||
is OlderLoadOutcome.Stopped -> buildJsonObject {
|
||||
put("kind", "stopped")
|
||||
put("reason", outcome.reason.wire)
|
||||
}
|
||||
is OlderLoadOutcome.Failed -> fail("older-page load failed: ${outcome.error.message}")
|
||||
}
|
||||
|
||||
private fun projectWindowState(state: MessageWindowState): JsonObject = buildJsonObject {
|
||||
put("messages", JsonArray(state.messages.map(::projectMessage)))
|
||||
put("hasMore", state.hasMore)
|
||||
put("epoch", state.epoch?.let(::JsonPrimitive) ?: JsonNull)
|
||||
put("viewMode", state.viewMode.wire)
|
||||
put("olderCursor", projectCursor(state.oldestPosition?.at, state.oldestPosition?.seq))
|
||||
put("newestCursor", projectCursor(state.newestPosition?.at, state.newestPosition?.seq))
|
||||
}
|
||||
|
||||
private fun projectMessage(message: WindowMessage): JsonObject = buildJsonObject {
|
||||
put("id", message.id)
|
||||
put("localId", message.localId?.let(::JsonPrimitive) ?: JsonNull)
|
||||
put("seq", message.seq?.let(::JsonPrimitive) ?: JsonNull)
|
||||
put("createdAt", message.createdAt)
|
||||
// Wire tri-state: the key appears only when the wire carried it.
|
||||
when (val invokedAt = message.wire.invokedAt) {
|
||||
is OptionalField.Present -> put("invokedAt", invokedAt.value?.let(::JsonPrimitive) ?: JsonNull)
|
||||
OptionalField.Absent -> Unit
|
||||
}
|
||||
message.wire.scheduledAt?.let { put("scheduledAt", it) }
|
||||
message.status?.let { put("status", it.wire) }
|
||||
put("queued", message.isQueuedForInvocation)
|
||||
put("optimistic", message.isOptimistic)
|
||||
}
|
||||
|
||||
private fun projectCursor(at: Long?, seq: Long?): JsonElement =
|
||||
if (at != null && seq != null) {
|
||||
buildJsonObject {
|
||||
put("at", at)
|
||||
put("seq", seq)
|
||||
}
|
||||
} else {
|
||||
JsonNull
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The scripted transport: serves queued [MessagesResponse]s FIFO and records
|
||||
* each request in the canonical shape the web harness pinned — `limit` only
|
||||
* for a latest page; `beforeAt`+`beforeSeq`+`limit` for older pages;
|
||||
* `afterAt`+`afterSeq`+`untilAt`+`untilSeq`+`epoch`+`limit` (untils
|
||||
* explicitly null on the first loop request) for tail catch-up.
|
||||
*/
|
||||
internal class ScriptedMessagesApi : MessagesApi {
|
||||
private val queue = ArrayDeque<MessagesResponse>()
|
||||
val requests = mutableListOf<JsonObject>()
|
||||
|
||||
val remaining: Int get() = queue.size
|
||||
|
||||
fun begin(responses: List<MessagesResponse>) {
|
||||
queue.clear()
|
||||
queue.addAll(responses)
|
||||
requests.clear()
|
||||
}
|
||||
|
||||
override suspend fun getMessages(sessionId: String, query: MessagesQuery): MessagesResponse {
|
||||
requests += query.toRequestJson()
|
||||
return queue.removeFirstOrNull()
|
||||
?: throw IllegalStateException("scripted MessagesApi exhausted: unexpected getMessages request")
|
||||
}
|
||||
|
||||
override suspend fun getQueuedState(sessionId: String, localIds: List<String>): QueuedStateResponse =
|
||||
throw UnsupportedOperationException("queued-state ops are replayed via store primitives, like the web runner")
|
||||
|
||||
private fun MessagesQuery.toRequestJson(): JsonObject = when (this) {
|
||||
is MessagesQuery.Latest -> buildJsonObject {
|
||||
put("limit", limit)
|
||||
}
|
||||
is MessagesQuery.Before -> buildJsonObject {
|
||||
put("beforeAt", beforeAt)
|
||||
put("beforeSeq", beforeSeq)
|
||||
put("limit", limit)
|
||||
}
|
||||
is MessagesQuery.After -> buildJsonObject {
|
||||
put("afterAt", afterAt)
|
||||
put("afterSeq", afterSeq)
|
||||
put("epoch", epoch)
|
||||
put("limit", limit)
|
||||
put("untilAt", untilAt?.let(::JsonPrimitive) ?: JsonNull)
|
||||
put("untilSeq", untilSeq?.let(::JsonPrimitive) ?: JsonNull)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user