index.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. /**
  2. * Durable, lifecycle-bound feedback for finalized assistant messages.
  3. * @module @deepseek-ai/dsh-message-feedback
  4. */
  5. import { Buffer } from 'node:buffer'
  6. import { randomUUID } from 'node:crypto'
  7. import { Context, Service } from '@deepseek-ai/cordis'
  8. import s from '@deepseek-ai/schemastery'
  9. import { deriveEventMessage, isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
  10. import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session/types'
  11. import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
  12. import type { KvTable } from '@deepseek-ai/dsh-storage-domain'
  13. import { GatewayService, Remote } from '@deepseek-ai/dsh-type-meta'
  14. import { messageFeedbackDomainSpec } from './spec.ts'
  15. import type { MessageFeedbackRow, MessageFeedbackSessionIdentity } from './spec.ts'
  16. import type {
  17. MessageFeedbackDeleteRequest,
  18. MessageFeedbackDeleteResult,
  19. MessageFeedbackDeleteValue,
  20. MessageFeedbackFailure,
  21. MessageFeedbackItem,
  22. MessageFeedbackListRequest,
  23. MessageFeedbackListResult,
  24. MessageFeedbackListValue,
  25. MessageFeedbackNoteBlank,
  26. MessageFeedbackNoteTooLarge,
  27. MessageFeedbackPutRequest,
  28. MessageFeedbackPutResult,
  29. MessageFeedbackRejected,
  30. MessageFeedbackSessionNotFound,
  31. MessageFeedbackSuccess,
  32. MessageFeedbackVersion,
  33. MessageFeedbackVersionConflict,
  34. } from './types.ts'
  35. export type * from './types.ts'
  36. export {
  37. messageFeedbackDomainSpec,
  38. messageFeedbackItemSchema,
  39. messageFeedbackRatingSchema,
  40. messageFeedbackRowSchema,
  41. messageFeedbackSessionIdentitySchema,
  42. messageFeedbackVersionSchema,
  43. } from './spec.ts'
  44. export type { MessageFeedbackRow, MessageFeedbackSessionIdentity } from './spec.ts'
  45. /** Required deployment policy for optional notes. */
  46. export interface Config {
  47. /** Maximum UTF-8 byte length accepted for one note. */
  48. readonly maxNoteBytes: number
  49. }
  50. declare module '@deepseek-ai/cordis' {
  51. interface Context {
  52. messageFeedback: MessageFeedbackService
  53. }
  54. }
  55. /** Immutable empty list reused only as an input to caller-owned copying. */
  56. const EMPTY_ITEMS: readonly MessageFeedbackItem[] = Object.freeze([])
  57. /** Validate the one deployment-varying limit at the configuration boundary. */
  58. function resolveMaxNoteBytes(value: number): number {
  59. if (!Number.isSafeInteger(value) || value < 1) {
  60. throw new TypeError(
  61. `message-feedback: maxNoteBytes must be a positive safe integer, got ${String(value)}`,
  62. )
  63. }
  64. return value
  65. }
  66. /** Copy and freeze one item before it crosses the service boundary. */
  67. function snapshotItem(item: MessageFeedbackItem): MessageFeedbackItem {
  68. return Object.freeze({
  69. messageId: item.messageId,
  70. rating: item.rating,
  71. ...(item.note === undefined ? {} : { note: item.note }),
  72. version: item.version,
  73. createdAt: item.createdAt,
  74. updatedAt: item.updatedAt,
  75. })
  76. }
  77. /** Copy and freeze a list response. */
  78. function snapshotList(items: readonly MessageFeedbackItem[]): MessageFeedbackListValue {
  79. return Object.freeze({ items: Object.freeze(items.map(snapshotItem)) })
  80. }
  81. /** Build a frozen success branch. */
  82. function success<T>(value: T): MessageFeedbackSuccess<T> {
  83. return Object.freeze({ ok: true, value })
  84. }
  85. /** Build a frozen business-failure branch. */
  86. function rejected<E extends MessageFeedbackFailure>(error: E): MessageFeedbackRejected<E> {
  87. return Object.freeze({ ok: false, error: Object.freeze(error) })
  88. }
  89. /** Project the Session fields that distinguish one persisted log lifecycle. */
  90. function identityOf(header: SessionHeader): MessageFeedbackSessionIdentity {
  91. return Object.freeze({
  92. createdAt: header.createdAt,
  93. ...(header.cwd === undefined ? {} : { cwd: header.cwd }),
  94. })
  95. }
  96. /** Whether a stored row belongs to the inspected Session lifecycle. */
  97. function sameIdentity(row: MessageFeedbackRow, header: SessionHeader): boolean {
  98. return row.session.createdAt === header.createdAt && row.session.cwd === header.cwd
  99. }
  100. /** Whether two observations name the same persisted Session lifecycle. */
  101. function sameHeaderIdentity(left: SessionHeader, right: SessionHeader): boolean {
  102. return left.id === right.id && left.createdAt === right.createdAt && left.cwd === right.cwd
  103. }
  104. /** Freeze the replacement row so storage-domain never exposes mutable aliases. */
  105. function rowSnapshot(
  106. session: MessageFeedbackSessionIdentity,
  107. items: readonly MessageFeedbackItem[],
  108. ): MessageFeedbackRow {
  109. const copiedItems = items.map(snapshotItem)
  110. Object.freeze(copiedItems)
  111. return Object.freeze({
  112. session,
  113. items: copiedItems,
  114. })
  115. }
  116. /** Generate an opaque equality token for one material mutation. */
  117. function nextVersion(): MessageFeedbackVersion {
  118. return randomUUID() as MessageFeedbackVersion
  119. }
  120. /** Session inspection result that keeps absence inside the business union. */
  121. type KnownSession =
  122. | MessageFeedbackSuccess<SessionInspection>
  123. | MessageFeedbackRejected<MessageFeedbackSessionNotFound>
  124. /** Validated note or one explicit request failure. */
  125. type ResolvedNote =
  126. | MessageFeedbackSuccess<string | undefined>
  127. | MessageFeedbackRejected<MessageFeedbackNoteBlank | MessageFeedbackNoteTooLarge>
  128. /**
  129. * Storage-domain sidecar service. It inspects persisted Session history and
  130. * never creates or resumes an Agent or Session.
  131. */
  132. export class MessageFeedbackService extends GatewayService {
  133. static inject = ['storageDomain', 'sessionPersistence', 'sessions']
  134. /** Loader validation for the required note-size policy. */
  135. static Config: s<Config> = s.object({
  136. maxNoteBytes: s.number().step(1).min(1).required(),
  137. })
  138. private readonly maxNoteBytes: number
  139. private table?: KvTable<SessionId, MessageFeedbackRow>
  140. private readonly operationTails = new Map<SessionId, Promise<void>>()
  141. private mutationAdmissionOpen = true
  142. /**
  143. * @param ctx - Host context carrying persistence and the storage-domain form.
  144. * @param config - Required note-size policy.
  145. */
  146. constructor(ctx: Context, config: Config) {
  147. super(ctx, 'messageFeedback')
  148. this.maxNoteBytes = resolveMaxNoteBytes(config.maxNoteBytes)
  149. }
  150. /** Open and own the one message-feedback sidecar domain. */
  151. protected async [Service.init](): Promise<void> {
  152. const domain = await this.ctx.storageDomain.open(messageFeedbackDomainSpec)
  153. this.ctx.effect(() => async () => {
  154. this.mutationAdmissionOpen = false
  155. await Promise.all(this.operationTails.values())
  156. await domain.close()
  157. }, 'message-feedback.domainClose')
  158. this.table = domain.table('sessions')
  159. }
  160. /**
  161. * Read feedback belonging to the current persisted Session lifecycle.
  162. * A stale row from a reused Session id is invisible.
  163. * @param request - Session identity to inspect and list.
  164. * @returns current immutable items or `session-not-found`.
  165. */
  166. @Remote('list')
  167. async list(request: MessageFeedbackListRequest): Promise<MessageFeedbackListResult> {
  168. const known = await this.inspectSession(request.sessionId)
  169. if (!known.ok) return known
  170. const row = this.requireTable().get(request.sessionId)
  171. const items = row !== undefined && sameIdentity(row, known.value.meta) ? row.items : EMPTY_ITEMS
  172. return success(snapshotList(items))
  173. }
  174. /**
  175. * Create or replace feedback for one derived append-origin assistant
  176. * message. Every request must match the addressed item's current version;
  177. * a matching no-op returns the stored item without changing its revision.
  178. * @param request - target, desired value, and observed item version.
  179. * @returns the committed item or an explicit business failure.
  180. */
  181. @Remote('put')
  182. put(request: MessageFeedbackPutRequest): Promise<MessageFeedbackPutResult> {
  183. const note = this.resolveNote(request.note)
  184. if (!note.ok) return Promise.resolve(note)
  185. return this.enqueue(request.sessionId, async () => {
  186. const known = await this.inspectSession(request.sessionId)
  187. if (!known.ok) return known
  188. if (!this.hasFeedbackTarget(known.value, request.messageId)) {
  189. return rejected({
  190. code: 'target-not-found',
  191. sessionId: request.sessionId,
  192. messageId: request.messageId,
  193. })
  194. }
  195. const durable = await this.ensureTargetDurable(known.value)
  196. if (!sameHeaderIdentity(durable.meta, known.value.meta)
  197. || !this.hasFeedbackTarget(durable, request.messageId)) {
  198. return rejected({
  199. code: 'target-not-found',
  200. sessionId: request.sessionId,
  201. messageId: request.messageId,
  202. })
  203. }
  204. const table = this.requireTable()
  205. const stored = table.get(request.sessionId)
  206. const current = stored !== undefined && sameIdentity(stored, durable.meta) ? stored : undefined
  207. const items = current?.items ?? EMPTY_ITEMS
  208. const index = items.findIndex(item => item.messageId === request.messageId)
  209. const existing = items[index]
  210. if (request.ifVersion !== (existing?.version ?? null)) {
  211. return rejected(this.versionConflict(existing ?? null))
  212. }
  213. if (existing !== undefined
  214. && existing.rating === request.rating
  215. && existing.note === note.value) {
  216. return success(snapshotItem(existing))
  217. }
  218. const now = Date.now()
  219. const item = snapshotItem({
  220. messageId: request.messageId,
  221. rating: request.rating,
  222. ...(note.value === undefined ? {} : { note: note.value }),
  223. version: nextVersion(),
  224. createdAt: existing?.createdAt ?? now,
  225. updatedAt: existing === undefined ? now : Math.max(now, existing.updatedAt),
  226. })
  227. const nextItems = [...items]
  228. if (index === -1) nextItems.push(item)
  229. else nextItems[index] = item
  230. await table.put(
  231. request.sessionId,
  232. rowSnapshot(identityOf(durable.meta), nextItems),
  233. )
  234. return success(snapshotItem(item))
  235. })
  236. }
  237. /**
  238. * Delete one feedback item. Absence is successful regardless of the
  239. * supplied version; an existing item requires an exact version match.
  240. * @param request - Session, message, and observed item version.
  241. * @returns the stable absent postcondition, or an explicit failure.
  242. */
  243. @Remote('delete')
  244. delete(request: MessageFeedbackDeleteRequest): Promise<MessageFeedbackDeleteResult> {
  245. return this.enqueue(request.sessionId, async () => {
  246. const known = await this.inspectSession(request.sessionId)
  247. if (!known.ok) return known
  248. const table = this.requireTable()
  249. const stored = table.get(request.sessionId)
  250. const current = stored !== undefined && sameIdentity(stored, known.value.meta) ? stored : undefined
  251. const items = current?.items ?? EMPTY_ITEMS
  252. const existing = items.find(item => item.messageId === request.messageId)
  253. if (existing === undefined) {
  254. return success<MessageFeedbackDeleteValue>(Object.freeze({ absent: true }))
  255. }
  256. if (request.ifVersion !== existing.version) {
  257. return rejected(this.versionConflict(existing))
  258. }
  259. await table.put(
  260. request.sessionId,
  261. rowSnapshot(identityOf(known.value.meta), items.filter(item => item !== existing)),
  262. )
  263. return success<MessageFeedbackDeleteValue>(Object.freeze({ absent: true }))
  264. })
  265. }
  266. /**
  267. * Resolve a live owner directly; otherwise use the storage catalog as the
  268. * existence authority before inspecting the log. Inspection failures for a
  269. * catalogued Session remain infrastructure failures rather than being
  270. * guessed into the business `session-not-found` branch.
  271. */
  272. private async inspectSession(sessionId: SessionId): Promise<KnownSession> {
  273. if (this.ctx.sessions.get(sessionId) === undefined) {
  274. const snapshots = await this.ctx.sessionPersistence.listSnapshots()
  275. if (!snapshots.some(snapshot => snapshot.header.id === sessionId)
  276. && this.ctx.sessions.get(sessionId) === undefined) {
  277. return rejected({ code: 'session-not-found', sessionId })
  278. }
  279. }
  280. return success(await this.ctx.sessionPersistence.inspect(sessionId))
  281. }
  282. /** Require the exact finalized append-origin assistant message projection. */
  283. private hasFeedbackTarget(inspection: SessionInspection, messageId: MessageFeedbackItem['messageId']): boolean {
  284. return inspection.events.some((event) => {
  285. if (event.type !== 'assistant/message' || !isAppendSurfaceEvent(event)) return false
  286. const message = deriveEventMessage(event)
  287. return message?.role === 'assistant' && message.id === messageId
  288. })
  289. }
  290. /**
  291. * Put the target log prefix behind a durability barrier before its sidecar.
  292. * A live owner flushes through the SessionStore's canonical checkpoint; a
  293. * cold owner is re-read from the physical durable prefix.
  294. */
  295. private async ensureTargetDurable(inspection: SessionInspection): Promise<SessionInspection> {
  296. const live = this.ctx.sessions.get(inspection.meta.id)
  297. if (live !== undefined && sameHeaderIdentity(live.header, inspection.meta)) {
  298. if (!(await this.ctx.sessions.flush(live))) {
  299. throw new Error(
  300. `message-feedback: no durability listener participated for live session '${inspection.meta.id}'`,
  301. )
  302. }
  303. return await this.ctx.sessionPersistence.readFrom(inspection.meta.id, 0)
  304. }
  305. return await this.ctx.sessionPersistence.readFrom(inspection.meta.id, 0)
  306. }
  307. /** Validate optional-note semantics and the configured complete UTF-8 byte bound. */
  308. private resolveNote(note: string | undefined): ResolvedNote {
  309. if (note === undefined) return success(undefined)
  310. if (note.trim().length === 0) return rejected({ code: 'note-blank' })
  311. const actualBytes = Buffer.byteLength(note, 'utf8')
  312. if (actualBytes > this.maxNoteBytes) {
  313. return rejected({ code: 'note-too-large', maxBytes: this.maxNoteBytes, actualBytes })
  314. }
  315. return success(note)
  316. }
  317. /** Return the authoritative item needed to reconcile one failed comparison. */
  318. private versionConflict(current: MessageFeedbackItem | null): MessageFeedbackVersionConflict {
  319. return {
  320. code: 'version-conflict',
  321. current: current === null ? null : snapshotItem(current),
  322. }
  323. }
  324. /** Queue a complete read/compare/write mutation behind this Session's prior mutation. */
  325. private enqueue<T>(sessionId: SessionId, operation: () => Promise<T>): Promise<T> {
  326. if (!this.mutationAdmissionOpen) {
  327. return Promise.reject(new Error('message-feedback: service is disposing'))
  328. }
  329. const previous = this.operationTails.get(sessionId) ?? Promise.resolve()
  330. const result = previous.then(operation)
  331. const tail = result.then(() => undefined, () => undefined)
  332. this.operationTails.set(sessionId, tail)
  333. return result.finally(() => {
  334. if (this.operationTails.get(sessionId) === tail) this.operationTails.delete(sessionId)
  335. })
  336. }
  337. /** Resolve the initialized durable table or fail a broken service lifecycle. */
  338. private requireTable(): KvTable<SessionId, MessageFeedbackRow> {
  339. if (this.table === undefined) {
  340. throw new Error('message-feedback: durable domain is not initialized')
  341. }
  342. return this.table
  343. }
  344. }
  345. export default MessageFeedbackService