| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383 |
- /**
- * Durable, lifecycle-bound feedback for finalized assistant messages.
- * @module @deepseek-ai/dsh-message-feedback
- */
- import { Buffer } from 'node:buffer'
- import { randomUUID } from 'node:crypto'
- import { Context, Service } from '@deepseek-ai/cordis'
- import s from '@deepseek-ai/schemastery'
- import { deriveEventMessage, isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
- import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session/types'
- import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
- import type { KvTable } from '@deepseek-ai/dsh-storage-domain'
- import { GatewayService, Remote } from '@deepseek-ai/dsh-type-meta'
- import { messageFeedbackDomainSpec } from './spec.ts'
- import type { MessageFeedbackRow, MessageFeedbackSessionIdentity } from './spec.ts'
- import type {
- MessageFeedbackDeleteRequest,
- MessageFeedbackDeleteResult,
- MessageFeedbackDeleteValue,
- MessageFeedbackFailure,
- MessageFeedbackItem,
- MessageFeedbackListRequest,
- MessageFeedbackListResult,
- MessageFeedbackListValue,
- MessageFeedbackNoteBlank,
- MessageFeedbackNoteTooLarge,
- MessageFeedbackPutRequest,
- MessageFeedbackPutResult,
- MessageFeedbackRejected,
- MessageFeedbackSessionNotFound,
- MessageFeedbackSuccess,
- MessageFeedbackVersion,
- MessageFeedbackVersionConflict,
- } from './types.ts'
- export type * from './types.ts'
- export {
- messageFeedbackDomainSpec,
- messageFeedbackItemSchema,
- messageFeedbackRatingSchema,
- messageFeedbackRowSchema,
- messageFeedbackSessionIdentitySchema,
- messageFeedbackVersionSchema,
- } from './spec.ts'
- export type { MessageFeedbackRow, MessageFeedbackSessionIdentity } from './spec.ts'
- /** Required deployment policy for optional notes. */
- export interface Config {
- /** Maximum UTF-8 byte length accepted for one note. */
- readonly maxNoteBytes: number
- }
- declare module '@deepseek-ai/cordis' {
- interface Context {
- messageFeedback: MessageFeedbackService
- }
- }
- /** Immutable empty list reused only as an input to caller-owned copying. */
- const EMPTY_ITEMS: readonly MessageFeedbackItem[] = Object.freeze([])
- /** Validate the one deployment-varying limit at the configuration boundary. */
- function resolveMaxNoteBytes(value: number): number {
- if (!Number.isSafeInteger(value) || value < 1) {
- throw new TypeError(
- `message-feedback: maxNoteBytes must be a positive safe integer, got ${String(value)}`,
- )
- }
- return value
- }
- /** Copy and freeze one item before it crosses the service boundary. */
- function snapshotItem(item: MessageFeedbackItem): MessageFeedbackItem {
- return Object.freeze({
- messageId: item.messageId,
- rating: item.rating,
- ...(item.note === undefined ? {} : { note: item.note }),
- version: item.version,
- createdAt: item.createdAt,
- updatedAt: item.updatedAt,
- })
- }
- /** Copy and freeze a list response. */
- function snapshotList(items: readonly MessageFeedbackItem[]): MessageFeedbackListValue {
- return Object.freeze({ items: Object.freeze(items.map(snapshotItem)) })
- }
- /** Build a frozen success branch. */
- function success<T>(value: T): MessageFeedbackSuccess<T> {
- return Object.freeze({ ok: true, value })
- }
- /** Build a frozen business-failure branch. */
- function rejected<E extends MessageFeedbackFailure>(error: E): MessageFeedbackRejected<E> {
- return Object.freeze({ ok: false, error: Object.freeze(error) })
- }
- /** Project the Session fields that distinguish one persisted log lifecycle. */
- function identityOf(header: SessionHeader): MessageFeedbackSessionIdentity {
- return Object.freeze({
- createdAt: header.createdAt,
- ...(header.cwd === undefined ? {} : { cwd: header.cwd }),
- })
- }
- /** Whether a stored row belongs to the inspected Session lifecycle. */
- function sameIdentity(row: MessageFeedbackRow, header: SessionHeader): boolean {
- return row.session.createdAt === header.createdAt && row.session.cwd === header.cwd
- }
- /** Whether two observations name the same persisted Session lifecycle. */
- function sameHeaderIdentity(left: SessionHeader, right: SessionHeader): boolean {
- return left.id === right.id && left.createdAt === right.createdAt && left.cwd === right.cwd
- }
- /** Freeze the replacement row so storage-domain never exposes mutable aliases. */
- function rowSnapshot(
- session: MessageFeedbackSessionIdentity,
- items: readonly MessageFeedbackItem[],
- ): MessageFeedbackRow {
- const copiedItems = items.map(snapshotItem)
- Object.freeze(copiedItems)
- return Object.freeze({
- session,
- items: copiedItems,
- })
- }
- /** Generate an opaque equality token for one material mutation. */
- function nextVersion(): MessageFeedbackVersion {
- return randomUUID() as MessageFeedbackVersion
- }
- /** Session inspection result that keeps absence inside the business union. */
- type KnownSession =
- | MessageFeedbackSuccess<SessionInspection>
- | MessageFeedbackRejected<MessageFeedbackSessionNotFound>
- /** Validated note or one explicit request failure. */
- type ResolvedNote =
- | MessageFeedbackSuccess<string | undefined>
- | MessageFeedbackRejected<MessageFeedbackNoteBlank | MessageFeedbackNoteTooLarge>
- /**
- * Storage-domain sidecar service. It inspects persisted Session history and
- * never creates or resumes an Agent or Session.
- */
- export class MessageFeedbackService extends GatewayService {
- static inject = ['storageDomain', 'sessionPersistence', 'sessions']
- /** Loader validation for the required note-size policy. */
- static Config: s<Config> = s.object({
- maxNoteBytes: s.number().step(1).min(1).required(),
- })
- private readonly maxNoteBytes: number
- private table?: KvTable<SessionId, MessageFeedbackRow>
- private readonly operationTails = new Map<SessionId, Promise<void>>()
- private mutationAdmissionOpen = true
- /**
- * @param ctx - Host context carrying persistence and the storage-domain form.
- * @param config - Required note-size policy.
- */
- constructor(ctx: Context, config: Config) {
- super(ctx, 'messageFeedback')
- this.maxNoteBytes = resolveMaxNoteBytes(config.maxNoteBytes)
- }
- /** Open and own the one message-feedback sidecar domain. */
- protected async [Service.init](): Promise<void> {
- const domain = await this.ctx.storageDomain.open(messageFeedbackDomainSpec)
- this.ctx.effect(() => async () => {
- this.mutationAdmissionOpen = false
- await Promise.all(this.operationTails.values())
- await domain.close()
- }, 'message-feedback.domainClose')
- this.table = domain.table('sessions')
- }
- /**
- * Read feedback belonging to the current persisted Session lifecycle.
- * A stale row from a reused Session id is invisible.
- * @param request - Session identity to inspect and list.
- * @returns current immutable items or `session-not-found`.
- */
- @Remote('list')
- async list(request: MessageFeedbackListRequest): Promise<MessageFeedbackListResult> {
- const known = await this.inspectSession(request.sessionId)
- if (!known.ok) return known
- const row = this.requireTable().get(request.sessionId)
- const items = row !== undefined && sameIdentity(row, known.value.meta) ? row.items : EMPTY_ITEMS
- return success(snapshotList(items))
- }
- /**
- * Create or replace feedback for one derived append-origin assistant
- * message. Every request must match the addressed item's current version;
- * a matching no-op returns the stored item without changing its revision.
- * @param request - target, desired value, and observed item version.
- * @returns the committed item or an explicit business failure.
- */
- @Remote('put')
- put(request: MessageFeedbackPutRequest): Promise<MessageFeedbackPutResult> {
- const note = this.resolveNote(request.note)
- if (!note.ok) return Promise.resolve(note)
- return this.enqueue(request.sessionId, async () => {
- const known = await this.inspectSession(request.sessionId)
- if (!known.ok) return known
- if (!this.hasFeedbackTarget(known.value, request.messageId)) {
- return rejected({
- code: 'target-not-found',
- sessionId: request.sessionId,
- messageId: request.messageId,
- })
- }
- const durable = await this.ensureTargetDurable(known.value)
- if (!sameHeaderIdentity(durable.meta, known.value.meta)
- || !this.hasFeedbackTarget(durable, request.messageId)) {
- return rejected({
- code: 'target-not-found',
- sessionId: request.sessionId,
- messageId: request.messageId,
- })
- }
- const table = this.requireTable()
- const stored = table.get(request.sessionId)
- const current = stored !== undefined && sameIdentity(stored, durable.meta) ? stored : undefined
- const items = current?.items ?? EMPTY_ITEMS
- const index = items.findIndex(item => item.messageId === request.messageId)
- const existing = items[index]
- if (request.ifVersion !== (existing?.version ?? null)) {
- return rejected(this.versionConflict(existing ?? null))
- }
- if (existing !== undefined
- && existing.rating === request.rating
- && existing.note === note.value) {
- return success(snapshotItem(existing))
- }
- const now = Date.now()
- const item = snapshotItem({
- messageId: request.messageId,
- rating: request.rating,
- ...(note.value === undefined ? {} : { note: note.value }),
- version: nextVersion(),
- createdAt: existing?.createdAt ?? now,
- updatedAt: existing === undefined ? now : Math.max(now, existing.updatedAt),
- })
- const nextItems = [...items]
- if (index === -1) nextItems.push(item)
- else nextItems[index] = item
- await table.put(
- request.sessionId,
- rowSnapshot(identityOf(durable.meta), nextItems),
- )
- return success(snapshotItem(item))
- })
- }
- /**
- * Delete one feedback item. Absence is successful regardless of the
- * supplied version; an existing item requires an exact version match.
- * @param request - Session, message, and observed item version.
- * @returns the stable absent postcondition, or an explicit failure.
- */
- @Remote('delete')
- delete(request: MessageFeedbackDeleteRequest): Promise<MessageFeedbackDeleteResult> {
- return this.enqueue(request.sessionId, async () => {
- const known = await this.inspectSession(request.sessionId)
- if (!known.ok) return known
- const table = this.requireTable()
- const stored = table.get(request.sessionId)
- const current = stored !== undefined && sameIdentity(stored, known.value.meta) ? stored : undefined
- const items = current?.items ?? EMPTY_ITEMS
- const existing = items.find(item => item.messageId === request.messageId)
- if (existing === undefined) {
- return success<MessageFeedbackDeleteValue>(Object.freeze({ absent: true }))
- }
- if (request.ifVersion !== existing.version) {
- return rejected(this.versionConflict(existing))
- }
- await table.put(
- request.sessionId,
- rowSnapshot(identityOf(known.value.meta), items.filter(item => item !== existing)),
- )
- return success<MessageFeedbackDeleteValue>(Object.freeze({ absent: true }))
- })
- }
- /**
- * Resolve a live owner directly; otherwise use the storage catalog as the
- * existence authority before inspecting the log. Inspection failures for a
- * catalogued Session remain infrastructure failures rather than being
- * guessed into the business `session-not-found` branch.
- */
- private async inspectSession(sessionId: SessionId): Promise<KnownSession> {
- if (this.ctx.sessions.get(sessionId) === undefined) {
- const snapshots = await this.ctx.sessionPersistence.listSnapshots()
- if (!snapshots.some(snapshot => snapshot.header.id === sessionId)
- && this.ctx.sessions.get(sessionId) === undefined) {
- return rejected({ code: 'session-not-found', sessionId })
- }
- }
- return success(await this.ctx.sessionPersistence.inspect(sessionId))
- }
- /** Require the exact finalized append-origin assistant message projection. */
- private hasFeedbackTarget(inspection: SessionInspection, messageId: MessageFeedbackItem['messageId']): boolean {
- return inspection.events.some((event) => {
- if (event.type !== 'assistant/message' || !isAppendSurfaceEvent(event)) return false
- const message = deriveEventMessage(event)
- return message?.role === 'assistant' && message.id === messageId
- })
- }
- /**
- * Put the target log prefix behind a durability barrier before its sidecar.
- * A live owner flushes through the SessionStore's canonical checkpoint; a
- * cold owner is re-read from the physical durable prefix.
- */
- private async ensureTargetDurable(inspection: SessionInspection): Promise<SessionInspection> {
- const live = this.ctx.sessions.get(inspection.meta.id)
- if (live !== undefined && sameHeaderIdentity(live.header, inspection.meta)) {
- if (!(await this.ctx.sessions.flush(live))) {
- throw new Error(
- `message-feedback: no durability listener participated for live session '${inspection.meta.id}'`,
- )
- }
- return await this.ctx.sessionPersistence.readFrom(inspection.meta.id, 0)
- }
- return await this.ctx.sessionPersistence.readFrom(inspection.meta.id, 0)
- }
- /** Validate optional-note semantics and the configured complete UTF-8 byte bound. */
- private resolveNote(note: string | undefined): ResolvedNote {
- if (note === undefined) return success(undefined)
- if (note.trim().length === 0) return rejected({ code: 'note-blank' })
- const actualBytes = Buffer.byteLength(note, 'utf8')
- if (actualBytes > this.maxNoteBytes) {
- return rejected({ code: 'note-too-large', maxBytes: this.maxNoteBytes, actualBytes })
- }
- return success(note)
- }
- /** Return the authoritative item needed to reconcile one failed comparison. */
- private versionConflict(current: MessageFeedbackItem | null): MessageFeedbackVersionConflict {
- return {
- code: 'version-conflict',
- current: current === null ? null : snapshotItem(current),
- }
- }
- /** Queue a complete read/compare/write mutation behind this Session's prior mutation. */
- private enqueue<T>(sessionId: SessionId, operation: () => Promise<T>): Promise<T> {
- if (!this.mutationAdmissionOpen) {
- return Promise.reject(new Error('message-feedback: service is disposing'))
- }
- const previous = this.operationTails.get(sessionId) ?? Promise.resolve()
- const result = previous.then(operation)
- const tail = result.then(() => undefined, () => undefined)
- this.operationTails.set(sessionId, tail)
- return result.finally(() => {
- if (this.operationTails.get(sessionId) === tail) this.operationTails.delete(sessionId)
- })
- }
- /** Resolve the initialized durable table or fail a broken service lifecycle. */
- private requireTable(): KvTable<SessionId, MessageFeedbackRow> {
- if (this.table === undefined) {
- throw new Error('message-feedback: durable domain is not initialized')
- }
- return this.table
- }
- }
- export default MessageFeedbackService
|