| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307 |
- /**
- * Canonical Session-log feedback for finalized assistant messages.
- * @module @deepseek-ai/dsh-message-feedback
- */
- import { Buffer } from 'node:buffer'
- import { randomUUID } from 'node:crypto'
- import { isDeepStrictEqual } from 'node:util'
- import { Context, Service } from '@deepseek-ai/cordis'
- import s from '@deepseek-ai/schemastery'
- import { z } from 'zod'
- import { SessionSeq } from '@deepseek-ai/dsh-session/types'
- import { deriveEventMessage, isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
- import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
- import type {} from '@deepseek-ai/dsh-session'
- import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
- import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
- import type {
- MessageFeedbackDeleteRequest,
- MessageFeedbackDeleteResult,
- MessageFeedbackFailure,
- MessageFeedbackItem,
- MessageFeedbackListRequest,
- MessageFeedbackListResult,
- MessageFeedbackNoteBlank,
- MessageFeedbackNoteTooLarge,
- MessageFeedbackPutRequest,
- MessageFeedbackPutResult,
- MessageFeedbackRejected,
- MessageFeedbackSessionNotFound,
- MessageFeedbackSuccess,
- MessageFeedbackVersion,
- MessageFeedbackVersionConflict,
- } from './types.ts'
- export type * from './types.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
- }
- interface Events {
- /**
- * Observe a durable cold feedback mutation without publishing a live Session.
- * Observers run before write ownership is released and must not await
- * another message-feedback operation for this Session. The payload is borrowed
- * read-only; deep-clone it before transferring ownership (for example, to Session.fromRestore).
- * @param inspection - committed canonical prefix, including the feedback as its last event.
- * @mode parallel
- */
- 'feedback/committed'(inspection: SessionInspection): void
- }
- }
- const timestamp = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
- const itemSchema = z.object({
- messageId: z.string().min(1),
- rating: z.enum(['positive', 'negative']),
- note: z.string().refine(note => note.trim().length > 0).optional(),
- version: z.uuid(),
- createdAt: timestamp,
- updatedAt: timestamp,
- }).refine(item => item.updatedAt >= item.createdAt)
- const putSchema = z.object({ sessionId: z.string().min(1), item: itemSchema })
- const deleteSchema = z.object({ sessionId: z.string().min(1), messageId: z.string().min(1) })
- type FeedbackEvent = SessionEvent<'feedback/message-put' | 'feedback/message-delete'>
- type Mutation = Pick<SessionEvent<'feedback/message-put'>, 'type' | 'data'>
- | Pick<SessionEvent<'feedback/message-delete'>, 'type' | 'data'>
- type Append = (event?: Mutation) => Promise<void>
- type MissingSession = MessageFeedbackRejected<MessageFeedbackSessionNotFound>
- type ResolvedNote = MessageFeedbackSuccess<string | undefined>
- | MessageFeedbackRejected<MessageFeedbackNoteBlank | MessageFeedbackNoteTooLarge>
- /** Return a caller-owned immutable value, detached from the log. */
- function snapshotItem(item: MessageFeedbackItem): MessageFeedbackItem {
- return Object.freeze({ ...item })
- }
- function success<T>(value: T): MessageFeedbackSuccess<T> {
- return Object.freeze({ ok: true, value })
- }
- function rejected<E extends MessageFeedbackFailure>(error: E): MessageFeedbackRejected<E> {
- return Object.freeze({ ok: false, error: Object.freeze(error) })
- }
- /** Validate persisted payloads before deriving current, Session-owned feedback. */
- function currentItems(sessionId: SessionId, events: readonly SessionEvent[]): MessageFeedbackItem[] {
- const items = new Map<MessageFeedbackItem['messageId'], MessageFeedbackItem>()
- for (const event of events) {
- switch (event.type) {
- case 'feedback/message-put':
- putSchema.parse(event.data)
- if (event.data.sessionId === sessionId) items.set(event.data.item.messageId, event.data.item)
- break
- case 'feedback/message-delete':
- deleteSchema.parse(event.data)
- if (event.data.sessionId === sessionId) items.delete(event.data.messageId)
- break
- default:
- // Other plugins' events do not change message feedback.
- break
- }
- }
- return [...items.values()]
- }
- /** Session-log service; cold operations never construct a Session or Agent. */
- export class MessageFeedbackService extends TypertRemoteService {
- static inject = ['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 readonly operationTails = new Map<SessionId, Promise<void>>()
- private mutationAdmissionOpen = true
- /**
- * @param ctx - Host context carrying Session persistence and live owners.
- * @param config - Required note-size policy.
- */
- constructor(ctx: Context, config: Config) {
- super(ctx, 'messageFeedback')
- if (!Number.isSafeInteger(config.maxNoteBytes) || config.maxNoteBytes < 1) {
- throw new TypeError('message-feedback: maxNoteBytes must be a positive safe integer')
- }
- this.maxNoteBytes = config.maxNoteBytes
- }
- protected [Service.init](): void {
- this.ctx.effect(() => async () => {
- this.mutationAdmissionOpen = false
- await Promise.all(this.operationTails.values())
- }, 'message-feedback.drain')
- }
- /**
- * Read current feedback from the canonical log.
- * @param request - Session to inspect.
- * @returns immutable items or a definite persistence miss.
- */
- @Remote('list')
- list(request: MessageFeedbackListRequest): Promise<MessageFeedbackListResult> {
- return this.enqueue(request.sessionId, () => this.withSession(request.sessionId, false, events =>
- success(Object.freeze({ items: Object.freeze(currentItems(request.sessionId, events).map(snapshotItem)) }))))
- }
- /**
- * Create or replace feedback after checking its current version.
- * Matching no-ops retain the version and append no event.
- * @param request - Target, desired value, and observed item version.
- * @returns the durable 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, () => this.withSession(request.sessionId, true, async (events, append) => {
- const items = currentItems(request.sessionId, events)
- if (!events.some(event => event.type === 'assistant/message'
- && isAppendSurfaceEvent(event)
- && deriveEventMessage(event)?.id === request.messageId)) {
- return rejected({ code: 'target-not-found', sessionId: request.sessionId, messageId: request.messageId })
- }
- const existing = items.find(item => item.messageId === request.messageId)
- if (request.ifVersion !== (existing?.version ?? null)) {
- return rejected(this.versionConflict(existing ?? null))
- }
- if (existing !== undefined && existing.rating === request.rating && existing.note === note.value) {
- await append()
- return success(snapshotItem(existing))
- }
- const now = Date.now()
- const item: MessageFeedbackItem = {
- messageId: request.messageId,
- rating: request.rating,
- ...(note.value === undefined ? {} : { note: note.value }),
- version: randomUUID() as MessageFeedbackVersion,
- createdAt: existing?.createdAt ?? now,
- updatedAt: existing === undefined ? now : Math.max(now, existing.updatedAt),
- }
- await append({ type: 'feedback/message-put', data: { sessionId: request.sessionId, item } })
- return success(snapshotItem(item))
- }))
- }
- /**
- * Delete one item after checking its version; absence succeeds without an event.
- * @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, () => this.withSession(request.sessionId, true, async (events, append) => {
- const existing = currentItems(request.sessionId, events).find(item => item.messageId === request.messageId)
- if (existing !== undefined) {
- if (request.ifVersion !== existing.version) return rejected(this.versionConflict(existing))
- await append({ type: 'feedback/message-delete', data: { sessionId: request.sessionId, messageId: request.messageId } })
- } else {
- await append()
- }
- return success(Object.freeze({ absent: true as const }))
- }))
- }
- /** Hold cold write ownership across read/compare/append; use live owners directly. */
- private async withSession<T>(
- sessionId: SessionId,
- write: boolean,
- operation: (events: readonly SessionEvent[], append: Append) => T | Promise<T>,
- ): Promise<T | MissingSession> {
- if (this.ctx.sessions.get(sessionId) === undefined
- && await this.ctx.sessionPersistence.stat(sessionId) === undefined
- && this.ctx.sessions.get(sessionId) === undefined) {
- return rejected({ code: 'session-not-found', sessionId })
- }
- const live = this.ctx.sessions.get(sessionId)
- if (live !== undefined) {
- return operation(live.snapshotEvents(), async (event) => {
- if (event !== undefined) {
- live.append(event.type, event.data)
- }
- const last = live.snapshotEvents().at(-1)
- if (!(await this.ctx.sessions.flush(live))) {
- throw new Error(
- `message-feedback: no durability listener participated for live session '${sessionId}'`,
- )
- }
- // Listener participation alone does not prove this Session has a persistence writer.
- const handle = await this.ctx.sessionPersistence.open(sessionId, 'read')
- try {
- const { events: stored } = await handle.read(last?.seq ?? 0, 1)
- if (!isDeepStrictEqual(
- [handle.header.id, handle.header.createdAt, handle.header.cwd],
- [live.header.id, live.header.createdAt, live.header.cwd],
- )
- || (last !== undefined && !isDeepStrictEqual(stored[0], last))) {
- throw new Error(`message-feedback: feedback prefix is not durable for live session '${sessionId}'`)
- }
- } finally {
- await handle.close()
- }
- })
- }
- const handle = await this.ctx.sessionPersistence.open(sessionId, write ? 'write' : 'read')
- try {
- const { events } = await handle.read()
- return await operation(events, async (event) => {
- const entry: FeedbackEvent | undefined = event === undefined ? undefined
- : { ...event, seq: SessionSeq(events.length), time: Date.now() }
- if (entry !== undefined) await handle.append([entry])
- await handle.flush()
- if (entry !== undefined) {
- try {
- await this.ctx.parallel('feedback/committed', {
- meta: handle.header,
- inheritedEventCount: handle.inheritedEventCount,
- events: [...events, entry],
- })
- } catch (error) {
- this.ctx.logger.warn('message-feedback: committed feedback observer failed', error)
- }
- }
- })
- } finally {
- await handle.close()
- }
- }
- 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)
- }
- private versionConflict(current: MessageFeedbackItem | null): MessageFeedbackVersionConflict {
- return { code: 'version-conflict', current: current === null ? null : snapshotItem(current) }
- }
- /** Serialize complete operations and drain their handles before disposal. */
- 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)
- })
- }
- }
- export default MessageFeedbackService
|