fix(feedback): address backend review gaps

This commit is contained in:
ZiyaZhang
2026-08-10 21:40:03 -07:00
parent c3d0fe1bf9
commit c61be60737
20 changed files with 893 additions and 16 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md
2026-08-10-message-feedback-sidecar.md: a741a7e6b9fe422c9aafb68d9b099466399e1ee9
2026-08-10-message-feedback-sidecar.zh.md: da176e13586bb93ebadcefc0afb44c21ce386e08
2026-08-10-message-feedback-sidecar.md: 780cbaa840fcac7bcfa799468bfd61f5b08715cb
2026-08-10-message-feedback-sidecar.zh.md: 72ecc82717010f65d013418a45c3b38c6084aaf9
@@ -22,7 +22,7 @@ Before `put` commits a sidecar row, it puts the target log behind a durability b
Each message item carries its own opaque version plus Host-assigned `createdAt` and `updatedAt` timestamps. `put` compares the caller's `ifVersion` only with the addressed item, so editing one message does not invalidate another. The comparison is strict even when the desired value already matches, preventing a stale request from crossing an ABA value cycle; a conflict returns the authoritative current item so callers can reconcile without a second read. A matching-version no-op preserves the version and timestamps, while a material update preserves `createdAt`, replaces the version, and keeps `updatedAt` from moving backward. An already-absent delete is likewise successful. Versions are tokens for equality, not counters callers may order or synthesize.
A per-Session mutation queue encloses lifecycle inspection, sidecar read, conflict evaluation, and whole-row write. This makes one service instance's mutations serial and preserves the per-message compare-and-swap contract inside one Host process. The underlying storage-domain API provides no cross-process conditional write, so the implementation claims no cross-process linearizability or lost-update protection.
A per-Session mutation queue encloses lifecycle inspection, sidecar read, conflict evaluation, and whole-row write. This makes one service instance's mutations serial and preserves the per-message compare-and-swap contract inside one Host process. Plugin disposal closes admission, drains accepted queue work, and then closes the storage domain. The underlying storage-domain API provides no cross-process conditional write, so the implementation claims no cross-process linearizability or lost-update protection.
`maxNoteBytes` is a required deployment choice and bounds the UTF-8 byte length of an optional note; the Web Host bundle sets it explicitly to `8192`. The package publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` contract directly through `GatewayService` and `@Remote`. Client Remote aggregate mounting and UI remain separately owned and deferred; their later adapter stays a thin consumer of this Host contract.
@@ -22,7 +22,7 @@ Status: implemented
每个消息条目都携带自己的 opaque version,以及 Host 分配的 `createdAt``updatedAt` 时间戳。`put` 只把调用方的 `ifVersion` 与目标条目比较,因此编辑一条消息不会使另一条消息失效。即使目标值已经相同,比较仍然严格执行,从而防止陈旧请求穿过 ABA 值循环;冲突会返回权威当前条目,调用方无需二次读取即可协调。携带匹配 version 的无变化请求会保留 version 与时间戳;实质更新保留 `createdAt`、替换 version,并保证 `updatedAt` 不倒退。删除已经不存在的条目也同样成功。version 是只能做相等比较的 token,不是调用方可以排序或自行合成的计数器。
按 Session 划分的变更队列覆盖生命周期检查、伴随记录读取、冲突判断与整行写入。这使同一个服务实例的变更串行化,并在单个 Host 进程内保持逐消息 compare-and-swap 契约。底层 storage-domain API 不提供跨进程条件写,因此实现不承诺跨进程线性一致性或防止丢失更新。
按 Session 划分的变更队列覆盖生命周期检查、伴随记录读取、冲突判断与整行写入。这使同一个服务实例的变更串行化,并在单个 Host 进程内保持逐消息 compare-and-swap 契约。Plugin disposal 会关闭接纳、排空已进入队列的工作,然后关闭 storage domain。底层 storage-domain API 不提供跨进程条件写,因此实现不承诺跨进程线性一致性或防止丢失更新。
`maxNoteBytes` 是必填的部署选择,用于限制可选备注的 UTF-8 字节长度;Web Host bundle 将其显式设为 `8192`。该包通过 `GatewayService``@Remote` 直接发布 Host `messageFeedback.list``messageFeedback.put``messageFeedback.delete` 契约。客户端 Remote 聚合挂载与 UI 由各自边界负责并保持延后;后续适配层只是该 Host 契约的薄消费者。
@@ -0,0 +1,115 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import {
assertFixtureInventory,
compareOrRefreshGolden,
launchWebScaffold,
seedSession,
type WebScaffold,
} from './scaffold.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-feedback-protocol', import.meta.url))
const SESSION_FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
const PROTOCOL_EXPECTED = join(SNAPSHOT_DIR, 'protocol.expected.json')
const SESSION_ID = 'message-feedback-protocol'
const MESSAGE_ID = '11111111-1111-4111-8111-111111111111'
interface ProtocolExchange {
readonly endpoint: string
readonly request: unknown
readonly status: number
readonly response: unknown
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
/** Extract the opaque item version while keeping every surrounding wire field snapshot-owned. */
function createdVersion(response: unknown): string {
if (!isRecord(response) || !isRecord(response.result) || response.result.ok !== true
|| !isRecord(response.result.value) || response.result.value.ok !== true
|| !isRecord(response.result.value.value)
|| typeof response.result.value.value.version !== 'string') {
throw new Error('messageFeedback.put did not return a successful versioned item')
}
return response.result.value.value.version
}
/** Replace only run-owned UUID/time values; all protocol names and business fields stay exact. */
function normalizeProtocol(exchanges: readonly ProtocolExchange[], version: string): string {
return JSON.stringify(exchanges, (key, value: unknown) => {
if ((key === 'version' || key === 'ifVersion') && value === version) return '{{version}}'
if ((key === 'createdAt' || key === 'updatedAt') && typeof value === 'number') return '{{timestamp}}'
return value
}, 2)
}
describe('message feedback Host Remote protocol', () => {
let scaffold: WebScaffold
beforeAll(async () => {
scaffold = await launchWebScaffold()
await seedSession(scaffold, await readFile(SESSION_FIXTURE, 'utf8'), SESSION_ID)
})
afterAll(async () => {
await scaffold?.close()
})
it('snapshots strict list, put, conflict, and delete calls through the shipped Web Host', async () => {
const exchanges: ProtocolExchange[] = []
const invoke = async (rpcId: string, endpoint: string, request: unknown): Promise<unknown> => {
const payload = { args: { request } }
const response = await fetch(`${scaffold.baseUrl}/api/${endpoint}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
type: 'client-request',
rpcId,
method: endpoint,
payload,
}),
})
const body: unknown = await response.json()
exchanges.push({ endpoint: `/api/${endpoint}`, request: payload, status: response.status, response: body })
return body
}
await invoke('feedback-invalid', 'messageFeedback/put', {
sessionId: SESSION_ID,
messageId: MESSAGE_ID,
rating: 'invalid-rating',
ifVersion: null,
})
await invoke('feedback-list-empty', 'messageFeedback/list', { sessionId: SESSION_ID })
const created = await invoke('feedback-put', 'messageFeedback/put', {
sessionId: SESSION_ID,
messageId: MESSAGE_ID,
rating: 'positive',
note: 'Useful answer',
ifVersion: null,
})
const version = createdVersion(created)
expect(version).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
await invoke('feedback-list-created', 'messageFeedback/list', { sessionId: SESSION_ID })
await invoke('feedback-conflict', 'messageFeedback/put', {
sessionId: SESSION_ID,
messageId: MESSAGE_ID,
rating: 'negative',
ifVersion: null,
})
await invoke('feedback-delete', 'messageFeedback/delete', {
sessionId: SESSION_ID,
messageId: MESSAGE_ID,
ifVersion: version,
})
await invoke('feedback-list-deleted', 'messageFeedback/list', { sessionId: SESSION_ID })
expect(exchanges.every(exchange => exchange.status === 200)).toBe(true)
await compareOrRefreshGolden(PROTOCOL_EXPECTED, normalizeProtocol(exchanges, version), scaffold.mode)
await assertFixtureInventory(SNAPSHOT_DIR, ['protocol.expected.json', 'session.jsonl'])
})
})
@@ -0,0 +1,203 @@
[
{
"endpoint": "/api/messageFeedback/put",
"request": {
"args": {
"request": {
"sessionId": "message-feedback-protocol",
"messageId": "11111111-1111-4111-8111-111111111111",
"rating": "invalid-rating",
"ifVersion": null
}
}
},
"status": 200,
"response": {
"type": "server-response",
"rpcId": "feedback-invalid",
"result": {
"ok": false,
"error": {
"code": "internal",
"message": "typert gateway: messageFeedback/put: wire field \"request\" failed boundary validation",
"details": {}
}
}
}
},
{
"endpoint": "/api/messageFeedback/list",
"request": {
"args": {
"request": {
"sessionId": "message-feedback-protocol"
}
}
},
"status": 200,
"response": {
"type": "server-response",
"rpcId": "feedback-list-empty",
"result": {
"ok": true,
"value": {
"ok": true,
"value": {
"items": []
}
}
}
}
},
{
"endpoint": "/api/messageFeedback/put",
"request": {
"args": {
"request": {
"sessionId": "message-feedback-protocol",
"messageId": "11111111-1111-4111-8111-111111111111",
"rating": "positive",
"note": "Useful answer",
"ifVersion": null
}
}
},
"status": 200,
"response": {
"type": "server-response",
"rpcId": "feedback-put",
"result": {
"ok": true,
"value": {
"ok": true,
"value": {
"messageId": "11111111-1111-4111-8111-111111111111",
"rating": "positive",
"note": "Useful answer",
"version": "{{version}}",
"createdAt": "{{timestamp}}",
"updatedAt": "{{timestamp}}"
}
}
}
}
},
{
"endpoint": "/api/messageFeedback/list",
"request": {
"args": {
"request": {
"sessionId": "message-feedback-protocol"
}
}
},
"status": 200,
"response": {
"type": "server-response",
"rpcId": "feedback-list-created",
"result": {
"ok": true,
"value": {
"ok": true,
"value": {
"items": [
{
"messageId": "11111111-1111-4111-8111-111111111111",
"rating": "positive",
"note": "Useful answer",
"version": "{{version}}",
"createdAt": "{{timestamp}}",
"updatedAt": "{{timestamp}}"
}
]
}
}
}
}
},
{
"endpoint": "/api/messageFeedback/put",
"request": {
"args": {
"request": {
"sessionId": "message-feedback-protocol",
"messageId": "11111111-1111-4111-8111-111111111111",
"rating": "negative",
"ifVersion": null
}
}
},
"status": 200,
"response": {
"type": "server-response",
"rpcId": "feedback-conflict",
"result": {
"ok": true,
"value": {
"ok": false,
"error": {
"code": "version-conflict",
"current": {
"messageId": "11111111-1111-4111-8111-111111111111",
"rating": "positive",
"note": "Useful answer",
"version": "{{version}}",
"createdAt": "{{timestamp}}",
"updatedAt": "{{timestamp}}"
}
}
}
}
}
},
{
"endpoint": "/api/messageFeedback/delete",
"request": {
"args": {
"request": {
"sessionId": "message-feedback-protocol",
"messageId": "11111111-1111-4111-8111-111111111111",
"ifVersion": "{{version}}"
}
}
},
"status": 200,
"response": {
"type": "server-response",
"rpcId": "feedback-delete",
"result": {
"ok": true,
"value": {
"ok": true,
"value": {
"absent": true
}
}
}
}
},
{
"endpoint": "/api/messageFeedback/list",
"request": {
"args": {
"request": {
"sessionId": "message-feedback-protocol"
}
}
},
"status": 200,
"response": {
"type": "server-response",
"rpcId": "feedback-list-deleted",
"result": {
"ok": true,
"value": {
"ok": true,
"value": {
"items": []
}
}
}
}
}
]
@@ -0,0 +1,7 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1786406400000,"cwd":"{{cwd}}"}
{"type":"turn/start","seq":0,"time":1786406400001,"data":{"turn":1}}
{"type":"user/message","seq":1,"time":1786406400002,"data":{"role":"user","content":[{"type":"text","text":"Give one useful answer."}],"source":{"kind":"user"},"id":"22222222-2222-4222-8222-222222222222"},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":1786406400003,"data":{"turn":1,"step":1}}
{"type":"assistant/message","seq":3,"time":1786406400004,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"A useful answer."}],"source":{"kind":"model","provider":"fixture","model":"fixture"},"id":"11111111-1111-4111-8111-111111111111"},"usage":{"inputTokens":4,"outputTokens":4}},"surfaceOp":"append"}
{"type":"step/end","seq":4,"time":1786406400005,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":5,"time":1786406400006,"data":{"turn":1,"reason":{"kind":"completed"}}}
+1
View File
@@ -25,6 +25,7 @@
"tests/scaffold.ts",
"tests/scaffold-hermetic.e2e.ts",
"tests/minimal-preset.snapshot.ts",
"tests/message-feedback-protocol.snapshot.ts",
"tests/live-interactions.e2e.ts",
"tests/question-composer.e2e.ts",
"tests/approval-composer.e2e.ts",
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/feedback.md
feedback.md: e5c99ee639403ec3da0cf5845ec00f5ea1a5817d
feedback.zh.md: 407d111da59af1d9a5c99c0e3d06d70df9051a13
feedback.md: 76a29f7d6ba604fa07ed56429c9b066e22639671
feedback.zh.md: 5a409832de68b6d0bc9688a907c0f22edd3b0a43
+180
View File
@@ -6,6 +6,183 @@ English | [中文](feedback.zh.md)
Source: [`packages/feedback/message-feedback/src/types.ts`](../../packages/feedback/message-feedback/src/types.ts)
## Public types
```ts type-equiv
/** Opaque compare-and-set token for one exact feedback item revision. */
type MessageFeedbackVersion = Branded<'MessageFeedbackVersion'>
```
```ts type-equiv
/** The human's overall judgment of one assistant message. */
type MessageFeedbackRating = 'positive' | 'negative'
```
```ts type-equiv
/** One current feedback value and its opaque mutation token. */
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
}
```
```ts type-equiv
/** Read all message feedback belonging to one persisted Session lifecycle. */
interface MessageFeedbackListRequest {
/** Persisted Session whose sidecar should be read. */
readonly sessionId: SessionId
}
```
```ts type-equiv
/** Current feedback values for one Session, in first-creation order. */
interface MessageFeedbackListValue {
/** Fresh immutable item snapshots. */
readonly items: readonly MessageFeedbackItem[]
}
```
```ts type-equiv
/** Create or replace feedback for one assistant message. */
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
}
```
```ts type-equiv
/** Delete feedback for one message after observing its current version. */
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
}
```
```ts type-equiv
/** Idempotent deletion acknowledgement. */
interface MessageFeedbackDeleteValue {
/** Stable postcondition shared by the first deletion and every retry. */
readonly absent: true
}
```
```ts type-equiv
/** No persisted Session header exists for the requested id. */
interface MessageFeedbackSessionNotFound {
readonly code: 'session-not-found'
readonly sessionId: SessionId
}
```
```ts type-equiv
/** The id does not name a derived, append-origin assistant message. */
interface MessageFeedbackTargetNotFound {
readonly code: 'target-not-found'
readonly sessionId: SessionId
readonly messageId: MessageId
}
```
```ts type-equiv
/** A material mutation did not match the addressed item's current version. */
interface MessageFeedbackVersionConflict {
readonly code: 'version-conflict'
/** Authoritative current item, or `null` when it does not exist. */
readonly current: MessageFeedbackItem | null
}
```
```ts type-equiv
/** A supplied note contains no non-whitespace character. */
interface MessageFeedbackNoteBlank {
readonly code: 'note-blank'
}
```
```ts type-equiv
/** A supplied note exceeds the configured UTF-8 byte limit. */
interface MessageFeedbackNoteTooLarge {
readonly code: 'note-too-large'
readonly maxBytes: number
readonly actualBytes: number
}
```
```ts type-equiv
/** Failures shared by the public message-feedback operations. */
type MessageFeedbackFailure =
| MessageFeedbackSessionNotFound
| MessageFeedbackTargetNotFound
| MessageFeedbackVersionConflict
| MessageFeedbackNoteBlank
| MessageFeedbackNoteTooLarge
```
```ts type-equiv
/** Successful public operation result. */
interface MessageFeedbackSuccess<T> {
readonly ok: true
readonly value: T
}
```
```ts type-equiv
/** Rejected public operation result with a stable business failure. */
interface MessageFeedbackRejected<E extends MessageFeedbackFailure> {
readonly ok: false
readonly error: E
}
```
```ts type-equiv
/** Result returned by the message-feedback `list` operation. */
type MessageFeedbackListResult =
| MessageFeedbackSuccess<MessageFeedbackListValue>
| MessageFeedbackRejected<MessageFeedbackSessionNotFound>
```
```ts type-equiv
/** Result returned by the message-feedback `put` operation. */
type MessageFeedbackPutResult =
| MessageFeedbackSuccess<MessageFeedbackItem>
| MessageFeedbackRejected<
| MessageFeedbackSessionNotFound
| MessageFeedbackTargetNotFound
| MessageFeedbackVersionConflict
| MessageFeedbackNoteBlank
| MessageFeedbackNoteTooLarge
>
```
```ts type-equiv
/** Result returned by the message-feedback `delete` operation. */
type MessageFeedbackDeleteResult =
| MessageFeedbackSuccess<MessageFeedbackDeleteValue>
| MessageFeedbackRejected<MessageFeedbackSessionNotFound | MessageFeedbackVersionConflict>
```
## Data and concurrency
One Session sidecar row contains its header identity `{createdAt, cwd}` and feedback items keyed by `MessageId`. Each item carries a positive or negative rating, an optional note, Host-assigned `createdAt`/`updatedAt` timestamps, and its own opaque version. Versions are compared only for equality and only against the addressed message; callers do not order or synthesize them.
@@ -22,12 +199,15 @@ The stored `{createdAt, cwd}` identity must match the inspected header. A mismat
The service stores whole Session rows in the `message_feedback` storage domain through `ctx.storageDomain`. Before `put` commits a row that references a target message, a matching live target passes through the canonical `ctx.sessions.flush` checkpoint; both live and cold paths are then physically read from sequence zero through `SessionPersistence.readFrom`. The resulting observation is revalidated before the sidecar write, so the durable target log always precedes its sidecar commit. `maxNoteBytes` is required and bounds note text by UTF-8 bytes; the Web Host composition sets `8192`. The package publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` unary Remote contract through `GatewayService` and `@Remote`; the generated Cordis surface below is the method-level authority.
Plugin disposal closes mutation admission, drains accepted per-Session queue work, and then closes the storage domain.
## Boundaries and limitations
- The client Remote aggregate mount and UI consumer are separately owned and deferred.
- The mutation queue is process-local. Storage-domain has no cross-process conditional write, so multiple Host writers to one storage root have no compare-and-swap or lost-update guarantee.
- Session persistence has no durable deletion surface. The service does not treat `session/disposed` or `host/session-removed` as deletion and therefore performs no fake cascade; orphan sidecar rows may remain after out-of-band log removal.
- A request in the narrow interval after live detach but before the persistence catalog materializes the header can receive `session-not-found`; callers retry after retirement materialization.
- Cold requests scan the complete Session snapshot catalog because persistence has no lookup-by-id metadata operation. One Session row also has no item-count or aggregate-byte cap; `maxNoteBytes` bounds only each note until a concrete consumer owns a row policy.
- Header identity detects a reused id only when `{createdAt, cwd}` differs; a cloned log retaining the same header identity is indistinguishable by this contract.
- The Host contract records no authenticated actor or audit identity and therefore assumes a trusted caller boundary.
+180
View File
@@ -6,6 +6,183 @@
来源:[`packages/feedback/message-feedback/src/types.ts`](../../packages/feedback/message-feedback/src/types.ts)
## 公开类型
```ts type-equiv
/** Opaque compare-and-set token for one exact feedback item revision. */
type MessageFeedbackVersion = Branded<'MessageFeedbackVersion'>
```
```ts type-equiv
/** The human's overall judgment of one assistant message. */
type MessageFeedbackRating = 'positive' | 'negative'
```
```ts type-equiv
/** One current feedback value and its opaque mutation token. */
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
}
```
```ts type-equiv
/** Read all message feedback belonging to one persisted Session lifecycle. */
interface MessageFeedbackListRequest {
/** Persisted Session whose sidecar should be read. */
readonly sessionId: SessionId
}
```
```ts type-equiv
/** Current feedback values for one Session, in first-creation order. */
interface MessageFeedbackListValue {
/** Fresh immutable item snapshots. */
readonly items: readonly MessageFeedbackItem[]
}
```
```ts type-equiv
/** Create or replace feedback for one assistant message. */
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
}
```
```ts type-equiv
/** Delete feedback for one message after observing its current version. */
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
}
```
```ts type-equiv
/** Idempotent deletion acknowledgement. */
interface MessageFeedbackDeleteValue {
/** Stable postcondition shared by the first deletion and every retry. */
readonly absent: true
}
```
```ts type-equiv
/** No persisted Session header exists for the requested id. */
interface MessageFeedbackSessionNotFound {
readonly code: 'session-not-found'
readonly sessionId: SessionId
}
```
```ts type-equiv
/** The id does not name a derived, append-origin assistant message. */
interface MessageFeedbackTargetNotFound {
readonly code: 'target-not-found'
readonly sessionId: SessionId
readonly messageId: MessageId
}
```
```ts type-equiv
/** A material mutation did not match the addressed item's current version. */
interface MessageFeedbackVersionConflict {
readonly code: 'version-conflict'
/** Authoritative current item, or `null` when it does not exist. */
readonly current: MessageFeedbackItem | null
}
```
```ts type-equiv
/** A supplied note contains no non-whitespace character. */
interface MessageFeedbackNoteBlank {
readonly code: 'note-blank'
}
```
```ts type-equiv
/** A supplied note exceeds the configured UTF-8 byte limit. */
interface MessageFeedbackNoteTooLarge {
readonly code: 'note-too-large'
readonly maxBytes: number
readonly actualBytes: number
}
```
```ts type-equiv
/** Failures shared by the public message-feedback operations. */
type MessageFeedbackFailure =
| MessageFeedbackSessionNotFound
| MessageFeedbackTargetNotFound
| MessageFeedbackVersionConflict
| MessageFeedbackNoteBlank
| MessageFeedbackNoteTooLarge
```
```ts type-equiv
/** Successful public operation result. */
interface MessageFeedbackSuccess<T> {
readonly ok: true
readonly value: T
}
```
```ts type-equiv
/** Rejected public operation result with a stable business failure. */
interface MessageFeedbackRejected<E extends MessageFeedbackFailure> {
readonly ok: false
readonly error: E
}
```
```ts type-equiv
/** Result returned by the message-feedback `list` operation. */
type MessageFeedbackListResult =
| MessageFeedbackSuccess<MessageFeedbackListValue>
| MessageFeedbackRejected<MessageFeedbackSessionNotFound>
```
```ts type-equiv
/** Result returned by the message-feedback `put` operation. */
type MessageFeedbackPutResult =
| MessageFeedbackSuccess<MessageFeedbackItem>
| MessageFeedbackRejected<
| MessageFeedbackSessionNotFound
| MessageFeedbackTargetNotFound
| MessageFeedbackVersionConflict
| MessageFeedbackNoteBlank
| MessageFeedbackNoteTooLarge
>
```
```ts type-equiv
/** Result returned by the message-feedback `delete` operation. */
type MessageFeedbackDeleteResult =
| MessageFeedbackSuccess<MessageFeedbackDeleteValue>
| MessageFeedbackRejected<MessageFeedbackSessionNotFound | MessageFeedbackVersionConflict>
```
## 数据与并发
每个 Session 的一条伴随记录包含 header 身份 `{createdAt, cwd}` 和以 `MessageId` 为键的反馈条目。每个条目携带好评或差评、可选备注、Host 分配的 `createdAt`/`updatedAt` 时间戳及自己的 opaque version。version 只能用于相等比较,且只与目标消息比较;调用方不能排序或自行合成它。
@@ -22,12 +199,15 @@
服务通过 `ctx.storageDomain` 在 `message_feedback` 存储域中保存完整 Session 行。`put` 提交引用目标消息的伴随记录前,身份匹配的 live 目标先经过权威 `ctx.sessions.flush` checkpoint;随后 live 与 cold 路径都会通过 `SessionPersistence.readFrom` 从序列零做物理复读。写入伴随记录前会再次校验所得观测,因此目标日志的持久提交始终先于其伴随记录。`maxNoteBytes` 为必填项,按 UTF-8 字节限制备注文本;Web Host 组合将其设为 `8192`。该包通过 `GatewayService` 与 `@Remote` 发布 Host `messageFeedback.list`、`messageFeedback.put` 和 `messageFeedback.delete` 一元 Remote 契约;下方生成的 Cordis surface 是方法级权威。
Plugin disposal 会先关闭变更接纳,排空已进入各 Session 队列的工作,然后才关闭 storage domain。
## 边界与限制
- 客户端 Remote 聚合挂载与 UI 消费方由各自边界负责并保持延后。
- 变更队列仅在进程内生效。storage-domain 没有跨进程条件写,因此多个 Host 写入同一存储根目录时,不提供 compare-and-swap 或防止丢失更新的保证。
- Session persistence 没有持久删除接口。服务不把 `session/disposed` 或 `host/session-removed` 当作删除,因此不伪造级联;在带外移除日志后,孤儿伴随记录可能继续存在。
- 请求若恰好落在 live detach 之后、persistence catalog 物化 header 之前的极短窗口,可能收到 `session-not-found`;调用方应在 retirement materialization 后重试。
- 由于 persistence 没有按 id 读取元数据的操作,cold 请求会扫描完整的 Session snapshot 目录。单个 Session 行也没有条目数或聚合字节上限;在具体消费方拥有行策略之前,`maxNoteBytes` 只限制每条备注。
- 只有 `{createdAt, cwd}` 不同时,header 身份才能识别复用的 id;本契约无法区分保留相同 header 身份的克隆日志。
- Host 契约不记录已认证的 actor 或审计身份,因此假设调用方边界可信。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/feedback/message-feedback/README.md
README.md: 948cdcd2ef0fa2d1bcea68fdc40f16be08e963a8
README.zh.md: 14e62eea0a182bc60be2c370653d1cb031d6b8a8
README.md: a9ebad25907e32435c8d2a65eb4b2cd9eaa89eeb
README.zh.md: 29cbee0c1ec2ee810948d895c762d2e6320e9b66
+4 -1
View File
@@ -12,7 +12,7 @@ Public request, value, version, and failure types are exported from the package
|---|---|
| `maxNoteBytes` | Required positive safe integer: maximum UTF-8 byte length of one optional note. |
Notes must contain at least one non-whitespace character, but accepted text is stored verbatim rather than trimmed. Omitting `note` means the desired value has no note, so an authorized material `put` clears an existing note.
Notes must contain at least one non-whitespace character, but accepted text is stored verbatim rather than trimmed. Omitting `note` means the desired value has no note, so a version-matched material `put` clears an existing note. Note validation precedes Session lookup and can therefore return `note-blank` or `note-too-large` for a missing Session without touching persistence.
```yaml
- id: message-feedback
@@ -55,6 +55,8 @@ A matching-version no-op returns the already stored item with unchanged version
A per-Session promise queue encloses inspection, durability validation, sidecar read, comparison, and whole-row write. These semantics serialize concurrent mutations through one service instance; storage-domain itself has no cross-process conditional write.
Plugin disposal closes mutation admission, drains every operation already accepted into the per-Session queues, and only then closes the storage domain. A mutation submitted after disposal begins rejects as a lifecycle failure instead of entering a closing domain.
## Model Experience
### Local message-feedback state
@@ -79,3 +81,4 @@ Independent. Listing or mutating message feedback does not touch a model request
- **Detach/catalog retirement window** — a request in the narrow interval after live detach but before the persistence catalog materializes the header can receive `session-not-found`; callers retry after retirement materialization.
- **Header identity is not a content fingerprint** — `{createdAt, cwd}` detects reuse only when those fields differ; a cloned log retaining the same header identity is indistinguishable.
- **Trusted caller boundary** — `list`/`put`/`delete` carry no authenticated actor or audit identity. A deployment must expose the Host gateway only through its trusted or separately authenticated boundary until authorization and attribution are added.
- **Catalog and row bounds** — a cold request scans the complete Session snapshot catalog because persistence has no lookup-by-id metadata operation. `maxNoteBytes` bounds one note, but the item count and aggregate retained bytes of one Session row are not capped; an indexed metadata read and deployment-owned row bound remain deferred until a concrete consumer defines their policy.
@@ -12,7 +12,7 @@
|---|---|
| `maxNoteBytes` | 必填正 safe integer:一条可选备注的最大 UTF-8 字节长度。 |
备注必须包含至少一个非空白字符,但通过校验的文本按原样存储,不会 trim。省略 `note` 表示目标值不含备注,因此通过授权的实质 `put` 会清除已有备注。
备注必须包含至少一个非空白字符,但通过校验的文本按原样存储,不会 trim。省略 `note` 表示目标值不含备注,因此 version 匹配的实质 `put` 会清除已有备注。备注校验早于 Session 查找,因此即使 Session 不存在,也可能在不访问持久化的情况下返回 `note-blank``note-too-large`
```yaml
- id: message-feedback
@@ -55,6 +55,8 @@ message feedback 不是 Session 日志内容或 Session 投影。它不发出 `f
按 Session 划分的 promise 队列覆盖检查、持久性校验、伴随记录读取、比较与整行写入。这些语义会串行化经由同一服务实例的并发变更;storage-domain 自身没有跨进程条件写。
Plugin disposal 会先关闭变更接纳,排空已进入各个 Session 队列的所有操作,然后才关闭 storage domain。disposal 开始后提交的变更会以生命周期故障拒绝,不会进入正在关闭的 domain。
## 模型体验
### 本地消息反馈状态
@@ -79,3 +81,4 @@ message feedback 不是 Session 日志内容或 Session 投影。它不发出 `f
- **Detach/catalog retirement 窗口**——请求若恰好落在 live detach 之后、persistence catalog 物化 header 之前的极短窗口,可能收到 `session-not-found`;调用方应在 retirement materialization 后重试。
- **Header 身份不是内容指纹**——只有 `{createdAt, cwd}` 不同时才能识别复用;本契约无法区分保留相同 header 身份的克隆日志。
- **调用方边界受信任**——`list`/`put`/`delete` 不携带已认证的 actor 或审计身份。在加入授权与归属信息前,部署方必须只通过受信任或另行认证的边界暴露 Host gateway。
- **目录与行边界**——由于 persistence 没有按 id 读取元数据的操作,cold 请求会扫描完整的 Session snapshot 目录。`maxNoteBytes` 只限制单条备注,单个 Session 行的条目数和聚合保留字节尚无上限;按索引读取元数据和由部署决定的行边界,延后到具体消费方明确策略时处理。
@@ -158,6 +158,7 @@ export class MessageFeedbackService extends GatewayService {
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.
@@ -171,7 +172,11 @@ export class MessageFeedbackService extends GatewayService {
/** 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.ctx.effect(() => async () => {
this.mutationAdmissionOpen = false
await Promise.all(this.operationTails.values())
await domain.close()
}, 'message-feedback.domainClose')
this.table = domain.table('sessions')
}
@@ -354,6 +359,9 @@ export class MessageFeedbackService extends GatewayService {
/** 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)
@@ -22,6 +22,8 @@ export const messageFeedbackVersionSchema = z.uuid()
.transform(value => value as MessageFeedbackVersion)
/** Runtime schema for one current feedback item. */
// Zod infers transformed branded fields structurally, so it cannot name the
// public interface even though every branded output is created below.
export const messageFeedbackItemSchema = z.object({
messageId: z.string().min(1).transform(value => value as MessageId),
rating: messageFeedbackRatingSchema,
@@ -116,7 +116,7 @@ class TestPersistence extends SessionPersistence {
inspectFailure: Error | undefined
inspectCalls = 0
readFromCalls = 0
onReadFrom: (() => void) | undefined
onReadFrom: (() => void | Promise<void>) | undefined
onListSnapshots: (() => void | Promise<void>) | undefined
locate(_meta: SessionHeader): SessionLocation | undefined { return undefined }
@@ -140,16 +140,16 @@ class TestPersistence extends SessionPersistence {
: Promise.resolve(stored)
}
readFrom(
async readFrom(
id: SessionId,
fromSeq: number,
): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
this.readFromCalls += 1
this.onReadFrom?.()
await this.onReadFrom?.()
const stored = this.durable.get(id)
return stored === undefined
? Promise.reject(new Error(`test persistence: session '${id}' not found`))
: Promise.resolve({ meta: stored.meta, events: stored.events.filter(event => event.seq >= fromSeq) })
: { meta: stored.meta, events: stored.events.filter(event => event.seq >= fromSeq) }
}
list(): Promise<SessionHeader[]> {
@@ -177,6 +177,7 @@ export interface TestHarness {
readonly ctx: Context
readonly persistence: TestPersistence
readonly root: string
disposeFeedback(): Promise<void>
dispose(): Promise<void>
}
@@ -184,22 +185,26 @@ export interface TestHarness {
export async function setupHarness(maxNoteBytes = 64): Promise<TestHarness> {
const root = await mkdtemp(join(tmpdir(), 'dsh-message-feedback-test-'))
const ctx = new Context()
let disposeFeedback: (() => Promise<void>) | undefined
try {
await ctx.plugin(SessionStore)
await ctx.plugin(TestPersistence)
await ctx.plugin(Storage)
await ctx.plugin(StorageJson, { root })
await ctx.plugin(StorageDomain, { backend: 'json' })
await ctx.plugin(MessageFeedbackService, { maxNoteBytes })
const feedbackFiber = await ctx.plugin(MessageFeedbackService, { maxNoteBytes })
disposeFeedback = feedbackFiber.dispose
} catch (error) {
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
throw error
}
if (disposeFeedback === undefined) throw new Error('message feedback test plugin did not load')
return {
ctx,
persistence: ctx.sessionPersistence as unknown as TestPersistence,
root,
disposeFeedback,
async dispose() {
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as MessageFeedbackInvariant from '../src/invariant.ts'
import { setupHarness } from './helpers.ts'
describe('message-feedback invariant companion', () => {
it('removes its registry contribution when its fiber is disposed (HMR safety)', async () => {
const harness = await setupHarness()
try {
await harness.ctx.plugin(InvariantService)
const fiber = await harness.ctx.plugin(MessageFeedbackInvariant)
expect(() => {
harness.ctx.invariants.register('@deepseek-ai/dsh-message-feedback', () => {})
}).toThrow(/already registered/u)
await fiber.dispose()
await expect(harness.ctx.plugin(MessageFeedbackInvariant).await()).resolves.toBeDefined()
} finally {
await harness.dispose()
}
})
})
@@ -459,6 +459,57 @@ describe('MessageFeedbackService item concurrency', () => {
}))
expect(newItem.version).not.toBe(oldItem.version)
})
it('drains admitted mutations before domain close and rejects later admission', async () => {
const current = await harness()
const { ctx, persistence } = current
const fixture = messageFixture('dispose-quiescence')
persistence.persist(fixture.session)
const service = ctx.messageFeedback
const lifecycle = service as unknown as { readonly mutationAdmissionOpen: boolean }
const started = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
let physicalReads = 0
let committed = 0
persistence.onReadFrom = async () => {
physicalReads += 1
if (physicalReads !== 1) return
started.resolve(undefined)
await release.promise
}
ctx.on('domain/changed', (change) => {
if (change.domain === 'message_feedback') committed += 1
})
const first = service.put({
sessionId: fixture.session.id,
messageId: fixture.assistantMessageIds[0],
rating: 'positive',
ifVersion: null,
})
await started.promise
const second = service.put({
sessionId: fixture.session.id,
messageId: fixture.assistantMessageIds[1],
rating: 'negative',
ifVersion: null,
})
const disposal = current.disposeFeedback()
await vi.waitFor(() => { expect(lifecycle.mutationAdmissionOpen).toBe(false) })
await expect(service.delete({
sessionId: fixture.session.id,
messageId: fixture.assistantMessageIds[0],
ifVersion: staleVersion(),
})).rejects.toThrow('message-feedback: service is disposing')
release.resolve(undefined)
expectItem(await first)
expectItem(await second)
await disposal
expect(physicalReads).toBe(2)
expect(committed).toBe(2)
})
})
describe('MessageFeedbackService durability ordering', () => {
+95
View File
@@ -1739,6 +1739,101 @@
"doc": "docs/subsystems/core.md",
"symbol": "AgentOptions",
"source": "packages/core/agent/src/runtime-types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackVersion",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackRating",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackItem",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackListRequest",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackListValue",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackPutRequest",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackDeleteRequest",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackDeleteValue",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackSessionNotFound",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackTargetNotFound",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackVersionConflict",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackNoteBlank",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackNoteTooLarge",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackFailure",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackSuccess",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackRejected",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackListResult",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackPutResult",
"source": "packages/feedback/message-feedback/src/types.ts"
},
{
"doc": "docs/subsystems/feedback.md",
"symbol": "MessageFeedbackDeleteResult",
"source": "packages/feedback/message-feedback/src/types.ts"
}
]
}
+1
View File
@@ -14,6 +14,7 @@
"apps/web/tests/support.ts",
"apps/web/tests/scaffold-hermetic.e2e.ts",
"apps/web/tests/minimal-preset.snapshot.ts",
"apps/web/tests/message-feedback-protocol.snapshot.ts",
"apps/web/tests/live-interactions.e2e.ts",
"apps/web/tests/question-composer.e2e.ts",
"apps/web/tests/approval-composer.e2e.ts",