index.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. /**
  2. * Canonical Session-log 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 { isDeepStrictEqual } from 'node:util'
  8. import { Context, Service } from '@deepseek-ai/cordis'
  9. import s from '@deepseek-ai/schemastery'
  10. import { z } from 'zod'
  11. import { SessionSeq } from '@deepseek-ai/dsh-session/types'
  12. import { deriveEventMessage, isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
  13. import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
  14. import type {} from '@deepseek-ai/dsh-session'
  15. import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
  16. import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
  17. import type {
  18. MessageFeedbackDeleteRequest,
  19. MessageFeedbackDeleteResult,
  20. MessageFeedbackFailure,
  21. MessageFeedbackItem,
  22. MessageFeedbackListRequest,
  23. MessageFeedbackListResult,
  24. MessageFeedbackNoteBlank,
  25. MessageFeedbackNoteTooLarge,
  26. MessageFeedbackPutRequest,
  27. MessageFeedbackPutResult,
  28. MessageFeedbackRejected,
  29. MessageFeedbackSessionNotFound,
  30. MessageFeedbackSuccess,
  31. MessageFeedbackVersion,
  32. MessageFeedbackVersionConflict,
  33. } from './types.ts'
  34. export type * from './types.ts'
  35. /** Required deployment policy for optional notes. */
  36. export interface Config {
  37. /** Maximum UTF-8 byte length accepted for one note. */
  38. readonly maxNoteBytes: number
  39. }
  40. declare module '@deepseek-ai/cordis' {
  41. interface Context {
  42. messageFeedback: MessageFeedbackService
  43. }
  44. interface Events {
  45. /**
  46. * Observe a durable cold feedback mutation without publishing a live Session.
  47. * Observers run before write ownership is released and must not await
  48. * another message-feedback operation for this Session. The payload is borrowed
  49. * read-only; deep-clone it before transferring ownership (for example, to Session.fromRestore).
  50. * @param inspection - committed canonical prefix, including the feedback as its last event.
  51. * @mode parallel
  52. */
  53. 'feedback/committed'(inspection: SessionInspection): void
  54. }
  55. }
  56. const timestamp = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
  57. const itemSchema = z.object({
  58. messageId: z.string().min(1),
  59. rating: z.enum(['positive', 'negative']),
  60. note: z.string().refine(note => note.trim().length > 0).optional(),
  61. version: z.uuid(),
  62. createdAt: timestamp,
  63. updatedAt: timestamp,
  64. }).refine(item => item.updatedAt >= item.createdAt)
  65. const putSchema = z.object({ sessionId: z.string().min(1), item: itemSchema })
  66. const deleteSchema = z.object({ sessionId: z.string().min(1), messageId: z.string().min(1) })
  67. type FeedbackEvent = SessionEvent<'feedback/message-put' | 'feedback/message-delete'>
  68. type Mutation = Pick<SessionEvent<'feedback/message-put'>, 'type' | 'data'>
  69. | Pick<SessionEvent<'feedback/message-delete'>, 'type' | 'data'>
  70. type Append = (event?: Mutation) => Promise<void>
  71. type MissingSession = MessageFeedbackRejected<MessageFeedbackSessionNotFound>
  72. type ResolvedNote = MessageFeedbackSuccess<string | undefined>
  73. | MessageFeedbackRejected<MessageFeedbackNoteBlank | MessageFeedbackNoteTooLarge>
  74. /** Return a caller-owned immutable value, detached from the log. */
  75. function snapshotItem(item: MessageFeedbackItem): MessageFeedbackItem {
  76. return Object.freeze({ ...item })
  77. }
  78. function success<T>(value: T): MessageFeedbackSuccess<T> {
  79. return Object.freeze({ ok: true, value })
  80. }
  81. function rejected<E extends MessageFeedbackFailure>(error: E): MessageFeedbackRejected<E> {
  82. return Object.freeze({ ok: false, error: Object.freeze(error) })
  83. }
  84. /** Validate persisted payloads before deriving current, Session-owned feedback. */
  85. function currentItems(sessionId: SessionId, events: readonly SessionEvent[]): MessageFeedbackItem[] {
  86. const items = new Map<MessageFeedbackItem['messageId'], MessageFeedbackItem>()
  87. for (const event of events) {
  88. switch (event.type) {
  89. case 'feedback/message-put':
  90. putSchema.parse(event.data)
  91. if (event.data.sessionId === sessionId) items.set(event.data.item.messageId, event.data.item)
  92. break
  93. case 'feedback/message-delete':
  94. deleteSchema.parse(event.data)
  95. if (event.data.sessionId === sessionId) items.delete(event.data.messageId)
  96. break
  97. default:
  98. // Other plugins' events do not change message feedback.
  99. break
  100. }
  101. }
  102. return [...items.values()]
  103. }
  104. /** Session-log service; cold operations never construct a Session or Agent. */
  105. export class MessageFeedbackService extends TypertRemoteService {
  106. static inject = ['sessionPersistence', 'sessions']
  107. /** Loader validation for the required note-size policy. */
  108. static Config: s<Config> = s.object({
  109. maxNoteBytes: s.number().step(1).min(1).required(),
  110. })
  111. private readonly maxNoteBytes: number
  112. private readonly operationTails = new Map<SessionId, Promise<void>>()
  113. private mutationAdmissionOpen = true
  114. /**
  115. * @param ctx - Host context carrying Session persistence and live owners.
  116. * @param config - Required note-size policy.
  117. */
  118. constructor(ctx: Context, config: Config) {
  119. super(ctx, 'messageFeedback')
  120. if (!Number.isSafeInteger(config.maxNoteBytes) || config.maxNoteBytes < 1) {
  121. throw new TypeError('message-feedback: maxNoteBytes must be a positive safe integer')
  122. }
  123. this.maxNoteBytes = config.maxNoteBytes
  124. }
  125. protected [Service.init](): void {
  126. this.ctx.effect(() => async () => {
  127. this.mutationAdmissionOpen = false
  128. await Promise.all(this.operationTails.values())
  129. }, 'message-feedback.drain')
  130. }
  131. /**
  132. * Read current feedback from the canonical log.
  133. * @param request - Session to inspect.
  134. * @returns immutable items or a definite persistence miss.
  135. */
  136. @Remote('list')
  137. list(request: MessageFeedbackListRequest): Promise<MessageFeedbackListResult> {
  138. return this.enqueue(request.sessionId, () => this.withSession(request.sessionId, false, events =>
  139. success(Object.freeze({ items: Object.freeze(currentItems(request.sessionId, events).map(snapshotItem)) }))))
  140. }
  141. /**
  142. * Create or replace feedback after checking its current version.
  143. * Matching no-ops retain the version and append no event.
  144. * @param request - Target, desired value, and observed item version.
  145. * @returns the durable item or an explicit business failure.
  146. */
  147. @Remote('put')
  148. put(request: MessageFeedbackPutRequest): Promise<MessageFeedbackPutResult> {
  149. const note = this.resolveNote(request.note)
  150. if (!note.ok) return Promise.resolve(note)
  151. return this.enqueue(request.sessionId, () => this.withSession(request.sessionId, true, async (events, append) => {
  152. const items = currentItems(request.sessionId, events)
  153. if (!events.some(event => event.type === 'assistant/message'
  154. && isAppendSurfaceEvent(event)
  155. && deriveEventMessage(event)?.id === request.messageId)) {
  156. return rejected({ code: 'target-not-found', sessionId: request.sessionId, messageId: request.messageId })
  157. }
  158. const existing = items.find(item => item.messageId === request.messageId)
  159. if (request.ifVersion !== (existing?.version ?? null)) {
  160. return rejected(this.versionConflict(existing ?? null))
  161. }
  162. if (existing !== undefined && existing.rating === request.rating && existing.note === note.value) {
  163. await append()
  164. return success(snapshotItem(existing))
  165. }
  166. const now = Date.now()
  167. const item: MessageFeedbackItem = {
  168. messageId: request.messageId,
  169. rating: request.rating,
  170. ...(note.value === undefined ? {} : { note: note.value }),
  171. version: randomUUID() as MessageFeedbackVersion,
  172. createdAt: existing?.createdAt ?? now,
  173. updatedAt: existing === undefined ? now : Math.max(now, existing.updatedAt),
  174. }
  175. await append({ type: 'feedback/message-put', data: { sessionId: request.sessionId, item } })
  176. return success(snapshotItem(item))
  177. }))
  178. }
  179. /**
  180. * Delete one item after checking its version; absence succeeds without an event.
  181. * @param request - Session, message, and observed item version.
  182. * @returns the stable absent postcondition or an explicit failure.
  183. */
  184. @Remote('delete')
  185. delete(request: MessageFeedbackDeleteRequest): Promise<MessageFeedbackDeleteResult> {
  186. return this.enqueue(request.sessionId, () => this.withSession(request.sessionId, true, async (events, append) => {
  187. const existing = currentItems(request.sessionId, events).find(item => item.messageId === request.messageId)
  188. if (existing !== undefined) {
  189. if (request.ifVersion !== existing.version) return rejected(this.versionConflict(existing))
  190. await append({ type: 'feedback/message-delete', data: { sessionId: request.sessionId, messageId: request.messageId } })
  191. } else {
  192. await append()
  193. }
  194. return success(Object.freeze({ absent: true as const }))
  195. }))
  196. }
  197. /** Hold cold write ownership across read/compare/append; use live owners directly. */
  198. private async withSession<T>(
  199. sessionId: SessionId,
  200. write: boolean,
  201. operation: (events: readonly SessionEvent[], append: Append) => T | Promise<T>,
  202. ): Promise<T | MissingSession> {
  203. if (this.ctx.sessions.get(sessionId) === undefined
  204. && await this.ctx.sessionPersistence.stat(sessionId) === undefined
  205. && this.ctx.sessions.get(sessionId) === undefined) {
  206. return rejected({ code: 'session-not-found', sessionId })
  207. }
  208. const live = this.ctx.sessions.get(sessionId)
  209. if (live !== undefined) {
  210. return operation(live.snapshotEvents(), async (event) => {
  211. if (event !== undefined) {
  212. live.append(event.type, event.data)
  213. }
  214. const last = live.snapshotEvents().at(-1)
  215. if (!(await this.ctx.sessions.flush(live))) {
  216. throw new Error(
  217. `message-feedback: no durability listener participated for live session '${sessionId}'`,
  218. )
  219. }
  220. // Listener participation alone does not prove this Session has a persistence writer.
  221. const handle = await this.ctx.sessionPersistence.open(sessionId, 'read')
  222. try {
  223. const { events: stored } = await handle.read(last?.seq ?? 0, 1)
  224. if (!isDeepStrictEqual(
  225. [handle.header.id, handle.header.createdAt, handle.header.cwd],
  226. [live.header.id, live.header.createdAt, live.header.cwd],
  227. )
  228. || (last !== undefined && !isDeepStrictEqual(stored[0], last))) {
  229. throw new Error(`message-feedback: feedback prefix is not durable for live session '${sessionId}'`)
  230. }
  231. } finally {
  232. await handle.close()
  233. }
  234. })
  235. }
  236. const handle = await this.ctx.sessionPersistence.open(sessionId, write ? 'write' : 'read')
  237. try {
  238. const { events } = await handle.read()
  239. return await operation(events, async (event) => {
  240. const entry: FeedbackEvent | undefined = event === undefined ? undefined
  241. : { ...event, seq: SessionSeq(events.length), time: Date.now() }
  242. if (entry !== undefined) await handle.append([entry])
  243. await handle.flush()
  244. if (entry !== undefined) {
  245. try {
  246. await this.ctx.parallel('feedback/committed', {
  247. meta: handle.header,
  248. inheritedEventCount: handle.inheritedEventCount,
  249. events: [...events, entry],
  250. })
  251. } catch (error) {
  252. this.ctx.logger.warn('message-feedback: committed feedback observer failed', error)
  253. }
  254. }
  255. })
  256. } finally {
  257. await handle.close()
  258. }
  259. }
  260. private resolveNote(note: string | undefined): ResolvedNote {
  261. if (note === undefined) return success(undefined)
  262. if (note.trim().length === 0) return rejected({ code: 'note-blank' })
  263. const actualBytes = Buffer.byteLength(note, 'utf8')
  264. if (actualBytes > this.maxNoteBytes) {
  265. return rejected({ code: 'note-too-large', maxBytes: this.maxNoteBytes, actualBytes })
  266. }
  267. return success(note)
  268. }
  269. private versionConflict(current: MessageFeedbackItem | null): MessageFeedbackVersionConflict {
  270. return { code: 'version-conflict', current: current === null ? null : snapshotItem(current) }
  271. }
  272. /** Serialize complete operations and drain their handles before disposal. */
  273. private enqueue<T>(sessionId: SessionId, operation: () => Promise<T>): Promise<T> {
  274. if (!this.mutationAdmissionOpen) return Promise.reject(new Error('message-feedback: service is disposing'))
  275. const previous = this.operationTails.get(sessionId) ?? Promise.resolve()
  276. const result = previous.then(operation)
  277. const tail = result.then(() => undefined, () => undefined)
  278. this.operationTails.set(sessionId, tail)
  279. return result.finally(() => {
  280. if (this.operationTails.get(sessionId) === tail) this.operationTails.delete(sessionId)
  281. })
  282. }
  283. }
  284. export default MessageFeedbackService