feat(feedback): add durable message feedback backend

This commit is contained in:
ZiyaZhang
2026-08-10 11:28:38 -07:00
parent aaef105b7b
commit 3cffc77719
43 changed files with 2333 additions and 25 deletions

View File

@@ -0,0 +1,380 @@
/**
* 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>>()
/**
* @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(() => () => 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. An exact desired-value retry returns the stored item before its
* stale or `null` version is considered a conflict.
* @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 (existing !== undefined
&& existing.rating === request.rating
&& existing.note === note.value) {
return success(snapshotItem(existing))
}
if (request.ifVersion !== (existing?.version ?? null)) {
return rejected(this.versionConflict(request, existing?.version ?? null))
}
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(request, existing.version))
}
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)) {
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 inspection
}
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)
}
/** Build a conflict branch without exposing an orderable version. */
private versionConflict(
request: Pick<MessageFeedbackPutRequest, 'sessionId' | 'messageId' | 'ifVersion'>,
actual: MessageFeedbackVersion | null,
): MessageFeedbackVersionConflict {
return {
code: 'version-conflict',
sessionId: request.sessionId,
messageId: request.messageId,
expected: request.ifVersion,
actual,
}
}
/** Queue a complete read/compare/write mutation behind this Session's prior mutation. */
private enqueue<T>(sessionId: SessionId, operation: () => Promise<T>): Promise<T> {
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

View File

@@ -0,0 +1,27 @@
/** Package-owned invariant companion. @module @deepseek-ai/dsh-message-feedback/invariant */
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-message-feedback'
/** Cordis companion plugin name. */
export const name = 'message-feedback-invariant'
/** Services required before the companion can reserve and check package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the private typed writer owns current row mutations,
* the domain schema validates rows on reopen, and no second authority exists.
*/
const install: InvariantInstaller = Object.assign(() => {}, { inject: ['messageFeedback'] })
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,88 @@
/**
* Durable storage-domain declaration for lifecycle-bound message feedback.
* @module @deepseek-ai/dsh-message-feedback/src/spec
*/
import { z } from 'zod'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
import type { MessageFeedbackItem, MessageFeedbackRating, MessageFeedbackVersion } from './types.ts'
const nonNegativeSafeInteger = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
/** Runtime schema for the closed rating vocabulary. */
export const messageFeedbackRatingSchema = z.union([
z.literal('positive'),
z.literal('negative'),
]) satisfies z.ZodType<MessageFeedbackRating>
/** Runtime schema for one opaque item version stored on disk. */
export const messageFeedbackVersionSchema = z.uuid()
.transform(value => value as MessageFeedbackVersion)
/** Runtime schema for one current feedback item. */
export const messageFeedbackItemSchema = z.object({
messageId: z.string().min(1).transform(value => value as MessageId),
rating: messageFeedbackRatingSchema,
note: z.string().refine(note => note.trim().length > 0, {
message: 'message feedback note must contain a non-whitespace character',
}).optional(),
version: messageFeedbackVersionSchema,
createdAt: nonNegativeSafeInteger,
updatedAt: nonNegativeSafeInteger,
}).refine(item => item.updatedAt >= item.createdAt, {
path: ['updatedAt'],
message: 'message feedback updatedAt must not precede createdAt',
}) as unknown as z.ZodType<MessageFeedbackItem>
/** Persisted Session fields that fence a sidecar row to one log lifecycle. */
export const messageFeedbackSessionIdentitySchema = z.object({
createdAt: nonNegativeSafeInteger,
cwd: z.string().optional(),
})
/** Persisted lifecycle identity inferred from its durable schema. */
export type MessageFeedbackSessionIdentity = z.infer<typeof messageFeedbackSessionIdentitySchema>
/**
* One whole-Session sidecar. Duplicate message ids would make item lookup
* ambiguous; duplicate versions would break their independent identity.
*/
export const messageFeedbackRowSchema = z.object({
session: messageFeedbackSessionIdentitySchema,
items: z.array(messageFeedbackItemSchema),
}).superRefine((row, ctx) => {
const messageIds = new Set<string>()
const versions = new Set<string>()
row.items.forEach((item, index) => {
if (messageIds.has(item.messageId)) {
ctx.addIssue({
code: 'custom',
path: ['items', index, 'messageId'],
message: `duplicate message feedback id '${item.messageId}'`,
})
}
messageIds.add(item.messageId)
if (versions.has(item.version)) {
ctx.addIssue({
code: 'custom',
path: ['items', index, 'version'],
message: `duplicate message feedback version '${item.version}'`,
})
}
versions.add(item.version)
})
})
/** Durable sidecar row inferred from {@link messageFeedbackRowSchema}. */
export type MessageFeedbackRow = z.infer<typeof messageFeedbackRowSchema>
/** One lifecycle-bound sidecar record per Session id. */
export const messageFeedbackDomainSpec = defineDomain({
name: 'message_feedback',
version: 0,
tables: {
sessions: domainTable<SessionId, MessageFeedbackRow>(messageFeedbackRowSchema),
},
})

View File

@@ -0,0 +1,151 @@
/**
* Public request, value, and failure vocabulary for per-message feedback.
* This module contains types only so generated Remote clients can consume it
* without importing Host runtime code.
* @module @deepseek-ai/dsh-message-feedback/types
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
/** Opaque compare-and-set token for one exact feedback item revision. */
export type MessageFeedbackVersion = Branded<'MessageFeedbackVersion'>
/** The human's overall judgment of one assistant message. */
export type MessageFeedbackRating = 'positive' | 'negative'
/** One current feedback value and its opaque mutation token. */
export interface MessageFeedbackItem {
/** Stable identity of the assistant message inside the owning Session. */
readonly messageId: MessageId
/** Overall positive or negative judgment. */
readonly rating: MessageFeedbackRating
/** Optional explanation, preserved verbatim after validation. */
readonly note?: string
/** Equality-only token replaced by every material create or update. */
readonly version: MessageFeedbackVersion
/** Host-assigned creation time in Unix epoch milliseconds. */
readonly createdAt: number
/** Host-assigned time of the most recent material update. */
readonly updatedAt: number
}
/** Read all message feedback belonging to one persisted Session lifecycle. */
export interface MessageFeedbackListRequest {
/** Persisted Session whose sidecar should be read. */
readonly sessionId: SessionId
}
/** Current feedback values for one Session, in first-creation order. */
export interface MessageFeedbackListValue {
/** Fresh immutable item snapshots. */
readonly items: readonly MessageFeedbackItem[]
}
/** Create or replace feedback for one assistant message. */
export interface MessageFeedbackPutRequest {
/** Persisted Session that owns the target message. */
readonly sessionId: SessionId
/** Target assistant-message identity. */
readonly messageId: MessageId
/** Desired overall judgment. */
readonly rating: MessageFeedbackRating
/** Optional non-blank explanation. */
readonly note?: string
/** Observed item version, or `null` to require that no item exists. */
readonly ifVersion: MessageFeedbackVersion | null
}
/** Delete feedback for one message after observing its current version. */
export interface MessageFeedbackDeleteRequest {
/** Persisted Session that owns the sidecar. */
readonly sessionId: SessionId
/** Message whose feedback should be absent after this operation. */
readonly messageId: MessageId
/** Observed item version; ignored when the item is already absent. */
readonly ifVersion: MessageFeedbackVersion
}
/** Idempotent deletion acknowledgement. */
export interface MessageFeedbackDeleteValue {
/** Stable postcondition shared by the first deletion and every retry. */
readonly absent: true
}
/** No persisted Session header exists for the requested id. */
export interface MessageFeedbackSessionNotFound {
readonly code: 'session-not-found'
readonly sessionId: SessionId
}
/** The id does not name a derived, append-origin assistant message. */
export interface MessageFeedbackTargetNotFound {
readonly code: 'target-not-found'
readonly sessionId: SessionId
readonly messageId: MessageId
}
/** A material mutation did not match the addressed item's current version. */
export interface MessageFeedbackVersionConflict {
readonly code: 'version-conflict'
readonly sessionId: SessionId
readonly messageId: MessageId
/** Version supplied by the caller (`null` means create-only). */
readonly expected: MessageFeedbackVersion | null
/** Current version, or `null` when the item does not exist. */
readonly actual: MessageFeedbackVersion | null
}
/** A supplied note contains no non-whitespace character. */
export interface MessageFeedbackNoteBlank {
readonly code: 'note-blank'
}
/** A supplied note exceeds the configured UTF-8 byte limit. */
export interface MessageFeedbackNoteTooLarge {
readonly code: 'note-too-large'
readonly maxBytes: number
readonly actualBytes: number
}
/** Failures shared by the public message-feedback operations. */
export type MessageFeedbackFailure =
| MessageFeedbackSessionNotFound
| MessageFeedbackTargetNotFound
| MessageFeedbackVersionConflict
| MessageFeedbackNoteBlank
| MessageFeedbackNoteTooLarge
/** Successful public operation result. */
export interface MessageFeedbackSuccess<T> {
readonly ok: true
readonly value: T
}
/** Rejected public operation result with a stable business failure. */
export interface MessageFeedbackRejected<E extends MessageFeedbackFailure> {
readonly ok: false
readonly error: E
}
/** Result returned by the message-feedback `list` operation. */
export type MessageFeedbackListResult =
| MessageFeedbackSuccess<MessageFeedbackListValue>
| MessageFeedbackRejected<MessageFeedbackSessionNotFound>
/** Result returned by the message-feedback `put` operation. */
export type MessageFeedbackPutResult =
| MessageFeedbackSuccess<MessageFeedbackItem>
| MessageFeedbackRejected<
| MessageFeedbackSessionNotFound
| MessageFeedbackTargetNotFound
| MessageFeedbackVersionConflict
| MessageFeedbackNoteBlank
| MessageFeedbackNoteTooLarge
>
/** Result returned by the message-feedback `delete` operation. */
export type MessageFeedbackDeleteResult =
| MessageFeedbackSuccess<MessageFeedbackDeleteValue>
| MessageFeedbackRejected<MessageFeedbackSessionNotFound | MessageFeedbackVersionConflict>