Merge remote-tracking branch 'origin/master' into feature/workspace-picker-composer

This commit is contained in:
NI0317
2026-08-11 11:11:46 +08:00
43 changed files with 510 additions and 45 deletions

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 packages/feedback/command-feedback/README.md
README.md: 52b8fb6a423fca69f76397deec36ecd22a6a6023
README.zh.md: ca74d53f2531a46c2c16aa1423cee52e89c8256f
README.md: 24a975476b6783b439d4ec94c449f2acbe0b432f
README.zh.md: 12a4dcace001442351916b17fca0d7e2f2c76245

View File

@@ -8,11 +8,24 @@ Trigger-independent session feedback plus human-facing `/feedback` capture. The
| Input | Result |
|---|---|
| `/feedback <text>` | Append `feedback/record` and acknowledge with `Feedback recorded for session {sessionId}` followed by `User: {userId}`. |
| `/feedback <text>` | Append `feedback/record` and acknowledge with `Feedback recorded for session {sessionId}`, `User: {userId}`, plus the session-sharing disclosure. |
| `/feedback` | Return a direct usage error. Whitespace-only input is treated as empty. |
Surrounding whitespace is discarded, but feedback is otherwise unparsed: no truncation, case folding, or control words. Text that looks like another command, such as `/feedback /plan felt slow`, is feedback content. Repeated commands each produce their own event; nothing is replaced or merged.
## Session-sharing disclosure
The acknowledgement names the receiving session id and reports how that session is shared, read from the mounted [`telemetry`](../../session/session-telemetry/README.md) service through the plugin context (`ctx.get('telemetry')`, never a declared injection). The disclosure is one sentence chosen from the backend's [`TelemetrySharingStatus`](../../session/session-telemetry/README.md):
| Disclosed status | Acknowledgement sentence |
|---|---|
| `full` | `Session sharing is enabled.` |
| `feedback-only` | `Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.` |
| `disabled` | `Session sharing is disabled.` |
| no service | `Session sharing is not configured.` |
The disclosure states the deployment's current sharing policy only; it never promises delivery or retention. With `full` or `feedback-only`, records are handed to the backend's non-blocking enqueue and the SDK owns batching, retry, and loss policy, so the sentence claims nothing about what reached a collector; `disabled` claims nothing about future reconfiguration. The disclosure adds no event and never enters the model surface.
## What this plugin does and does not do
`recordFeedback(session, text)` is the command-independent write path. It rejects empty normalized text and appends `feedback/record { text }`; a different UI, hook, or host integration can call it without constructing a slash command. The `/feedback` handler uses that producer and starts no model work. The optional [`dsh-session-telemetry-otel`](../../session/session-telemetry-otel) consumer observes the event without changing its capture contract.
@@ -56,4 +69,5 @@ Independent of the model request path. Recording appends to the session log only
- **No structured fields** — an entry is one free-text string with no category, severity, or referenced-event link, so feedback cannot be filtered by subject without re-reading its text.
- **No amend or withdraw** — the session log is append-only and this package adds no tombstone, so a mistaken entry stays recorded and can only be superseded by a later one.
- **No explicit durability barrier** — the acknowledgement follows the append, not a flush, so an entry recorded immediately before a crash can be lost with any other unflushed tail. Feedback is not worth forcing a synchronous disk write for; a consumer that needs one awaits `ctx.sessions.flush(session)`.
- **No visible acknowledgement on a fresh session** — the web transcript renders command rows only once a session is active, so `/feedback` on a still-blank session records the event but shows no acknowledgement row. Recording feedback after the first message renders normally.
- **Web only among the shipped entry points** — headless mode, ACP automation, and JSON-RPC do not provide a command adapter, so `/feedback` is unavailable there.

View File

@@ -8,11 +8,24 @@
| 输入 | 结果 |
|---|---|
| `/feedback <text>` | 追加 `feedback/record`,并以 `Feedback recorded for session {sessionId}` 确认,随后显示 `User: {userId}`。 |
| `/feedback <text>` | 追加 `feedback/record`,并以 `Feedback recorded for session {sessionId}``User: {userId}` 加会话共享披露确认。 |
| `/feedback` | 返回一个直接用法错误。仅含空白的输入视为空输入。 |
前后空白会被丢弃,但除此之外,反馈内容不会被解析:没有截断、大小写折叠或控制词。看起来像另一个命令的文本(例如 `/feedback /plan felt slow`)就是反馈内容。重复执行命令时,每次都会产生一个事件;不会发生替换或合并。
## 会话共享披露
确认文本会点名接收会话的 id并报告该会话如何被共享该信息通过插件上下文`ctx.get('telemetry')`,绝不是声明的注入)从已挂载的 [`telemetry`](../../session/session-telemetry/README.md) 服务读取。披露是依据后端 [`TelemetrySharingStatus`](../../session/session-telemetry/README.md) 选择的一句话:
| 披露的状态 | 确认文本中的句子 |
|---|---|
| `full` | `Session sharing is enabled.` |
| `feedback-only` | `Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.` |
| `disabled` | `Session sharing is disabled.` |
| 无服务 | `Session sharing is not configured.` |
披露只陈述部署当前的共享策略,绝不承诺投递或留存:在 `full``feedback-only` 下,记录被交给后端的非阻塞入队,批处理、重试与丢失策略归 SDK 负责,因此句子不声称任何内容已到达采集端;`disabled` 也不声称未来不会重新配置。披露不新增任何事件,也绝不会进入模型 surface。
## 本插件做什么、不做什么
`recordFeedback(session, text)` 是不依赖命令的写入路径。它拒绝规范化后为空的文本,并追加 `feedback/record { text }`;其他 UI、钩子或 host 集成无需构造斜杠命令即可调用它。`/feedback` 处理器通过该生产方写入,且不启动任何模型工作。可选的 [`dsh-session-telemetry-otel`](../../session/session-telemetry-otel) 消费方会观察该事件,但不改变它的采集约定。
@@ -56,4 +69,5 @@
- **没有结构化字段**:一条条目就是一个自由文本字符串,没有类别、严重程度或关联事件链接,因此无法在不重读文本的情况下按主题过滤反馈。
- **不支持修改或撤回**:会话日志是仅追加的,本包也不新增 tombstone因此错误的条目会一直保留在记录中只能由后续条目取代。
- **没有显式持久化屏障**:确认文本紧随追加而非 flush因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。为反馈强制同步写盘并不值得;需要该保证的消费方可自行等待 `ctx.sessions.flush(session)`
- **新会话上没有可见的确认**Web 转录只在会话激活后渲染命令行,因此在仍为空白的新会话上执行 `/feedback` 会记录事件但不会显示确认行。发送首条消息后再记录反馈即可正常渲染。
- **随附的产品入口中只有 Web 使用此命令**无头模式、ACP 自动化和 JSON-RPC 不提供命令适配器,因此 `/feedback` 在那里不可用。

View File

@@ -35,6 +35,7 @@
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-telemetry": "workspace:^",
"@deepseek-ai/dsh-user-id": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
@@ -46,6 +47,7 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-telemetry": "workspace:^",
"@deepseek-ai/dsh-user-id": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}

View File

@@ -8,6 +8,7 @@
import type { Context } from '@deepseek-ai/cordis'
import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands'
import type { Telemetry, TelemetrySharingStatus } from '@deepseek-ai/dsh-session-telemetry'
import type { Session } from '@deepseek-ai/dsh-session'
import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id'
@@ -16,6 +17,42 @@ export const inject = ['commands']
const USAGE = 'Usage: /feedback <text>'
/** Fail closed when a future sharing status reaches the sentence switch. */
/* v8 ignore next 3 -- only the ignored default arm calls this; the closed union cannot reach it via the public API. */
function assertNever(value: never): never {
throw new Error(`command-feedback: unsupported sharing status ${JSON.stringify(value)}`)
}
/** The acknowledgement's sharing sentence for a disclosed policy. */
function sharingSentence(sharing: TelemetrySharingStatus): string {
switch (sharing) {
case 'full':
return 'Session sharing is enabled.'
case 'feedback-only':
return 'Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.'
case 'disabled':
return 'Session sharing is disabled.'
/* v8 ignore next 2 -- the seam's closed union cannot reach the default; a future status must be given a sentence here. */
default:
return assertNever(sharing)
}
}
/**
* The sharing disclosure appended to the acknowledgement: the mounted
* backend's disclosed policy, or a "not configured" notice when no backend
* is mounted. Read through the plugin context so the command still works
* when the telemetry service is absent.
* @param telemetry - the mounted telemetry service, or undefined.
* @returns one sentence describing this session's sharing policy.
*/
function sharingDisclosure(telemetry: Telemetry | undefined): string {
if (telemetry === undefined) {
return 'Session sharing is not configured.'
}
return sharingSentence(telemetry.sharing)
}
declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap {
/**
@@ -42,17 +79,20 @@ export function recordFeedback(session: Session, text: string): void {
* Validate, record, and acknowledge one feedback entry. Returning an error
* leaves no `feedback/record` event.
* @param invocation - receiving agent, raw command input, and UI cancellation.
* @param ctx - plugin context used to read the optional telemetry service.
* @returns an acknowledgement containing the receiving session and anonymous
* user ids, or a usage error when no feedback text was supplied.
* user ids plus the session-sharing disclosure, or a usage error when no
* feedback text was supplied.
*/
function executeFeedbackCommand(invocation: CommandInvocation): CommandResult {
function executeFeedbackCommand(invocation: CommandInvocation, ctx: Context): CommandResult {
if (invocation.rawInput.trim().length === 0) {
return { kind: 'error', text: `Feedback text is required. ${USAGE}` }
}
recordFeedback(invocation.agent.session, invocation.rawInput)
const telemetry = ctx.get('telemetry')
return {
kind: 'success',
text: `Feedback recorded for session ${invocation.agent.session.id}\nUser: ${getOrCreateAnonymousUserId()}`,
text: `Feedback recorded for session ${invocation.agent.session.id}\nUser: ${getOrCreateAnonymousUserId()}. ${sharingDisclosure(telemetry)}`,
}
}
@@ -63,6 +103,6 @@ export function apply(ctx: Context): void {
description: 'record feedback about this session',
input: { hint: '<text>' },
recordInput: false,
handler: executeFeedbackCommand,
handler: invocation => executeFeedbackCommand(invocation, ctx),
})
}

View File

@@ -5,6 +5,7 @@ import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import CommandService from '@deepseek-ai/dsh-commands'
import SessionStore, { foldSurface, Session, SessionId } from '@deepseek-ai/dsh-session'
import { Telemetry, type TelemetrySharingStatus } from '@deepseek-ai/dsh-session-telemetry'
import * as commandFeedback from '@deepseek-ai/dsh-command-feedback'
const { USER_ID, getOrCreateAnonymousUserId } = vi.hoisted(() => {
@@ -25,6 +26,20 @@ interface Harness {
readonly plugin: Awaited<ReturnType<Context['plugin']>>
}
/** Minimal mounted backend disclosing one sharing policy. */
class FakeTelemetry extends Telemetry {
override readonly sharing: TelemetrySharingStatus
constructor(ctx: Context, config: { sharing: TelemetrySharingStatus }) {
super(ctx)
this.sharing = config.sharing
}
emit(): void {}
async shutdown(): Promise<void> {}
}
/** Build a live idle agent over a store-owned session, as an app's spine does. */
function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } {
const session = ctx.sessions.create(SessionId(id))
@@ -48,12 +63,17 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session }
return { agent, session }
}
/** Mount the real command registry and this producer. */
async function harness(): Promise<Harness> {
/**
* Mount the real command registry, this producer, and optionally a telemetry
* backend disclosing one sharing policy. Without `sharing`, no telemetry
* service exists and the acknowledgement reports "not configured".
*/
async function harness(sharing?: TelemetrySharingStatus): Promise<Harness> {
const ctx = new Context()
await ctx.plugin(CommandService)
await ctx.plugin(AgentRegistry)
await ctx.plugin(SessionStore)
if (sharing !== undefined) await ctx.plugin(FakeTelemetry, { sharing })
const plugin = await ctx.plugin(commandFeedback)
const { agent, session } = stubAgent(ctx, `command-feedback-${Math.random()}`)
ctx.agents.register(agent)
@@ -104,7 +124,7 @@ describe('/feedback human command', () => {
const test = await harness()
await expect(run(test, ' the diff view is unreadable')).resolves.toEqual({
kind: 'success',
text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}`,
text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is not configured.`,
})
expect(feedbackTexts(test.session)).toEqual(['the diff view is unreadable'])
const commandRun = test.session.events.find(event => event.type === 'command/run')
@@ -152,12 +172,39 @@ describe('/feedback human command', () => {
test.ctx.commands.execute(test.agent, '/feedback second', signal),
])
expect(settled.map(item => item?.result)).toEqual([
{ kind: 'success', text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}` },
{ kind: 'success', text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}` },
{ kind: 'success', text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is not configured.` },
{ kind: 'success', text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is not configured.` },
])
expect(feedbackTexts(test.session)).toEqual(['first', 'second'])
})
it('discloses full session sharing in the acknowledgement', async () => {
const test = await harness('full')
await expect(run(test, ' everything shared')).resolves.toEqual({
kind: 'success',
text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is enabled.`,
})
expect(feedbackTexts(test.session)).toEqual(['everything shared'])
})
it('discloses feedback-gated session sharing in the acknowledgement', async () => {
const test = await harness('feedback-only')
await expect(run(test, ' gated sharing')).resolves.toEqual({
kind: 'success',
text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.`,
})
expect(feedbackTexts(test.session)).toEqual(['gated sharing'])
})
it('discloses disabled session sharing in the acknowledgement', async () => {
const test = await harness('disabled')
await expect(run(test, ' local only')).resolves.toEqual({
kind: 'success',
text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is disabled.`,
})
expect(feedbackTexts(test.session)).toEqual(['local only'])
})
it('keeps every recorded event off the model surface and out of derived history', async () => {
const test = await harness()
await run(test, ' invisible to the model')

View File

@@ -93,7 +93,7 @@ describe('/feedback real Loader composition through cordis.yml', () => {
const userId = getOrCreateAnonymousUserId({ env: { DSH_HOME: root } })
expect(accepted?.result).toEqual({
kind: 'success',
text: `Feedback recorded for session feedback-loader-agent\nUser: ${userId}`,
text: `Feedback recorded for session feedback-loader-agent\nUser: ${userId}. Session sharing is not configured.`,
})
const rejected = await context.commands.execute(owner, '/feedback', signal)
expect(rejected?.result).toEqual({

View File

@@ -23,6 +23,9 @@
{
"path": "../../session/user-id"
},
{
"path": "../../session/session-telemetry"
},
{
"path": "../../support/invariants"
}

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 packages/session/session-telemetry-otel/README.md
README.md: 585995ce409255df9608bc33b76625374bc67669
README.zh.md: 7f0b93363fbb4aebb80f0d3cc8108e58ce3f647f
README.md: e3eae475a180419c7822d51858ae156052a663d6
README.zh.md: cfdf36ac5783850cc5e63bbb2b622584f1064b0c

View File

@@ -29,6 +29,8 @@ Programmatic TypeScript configuration uses the exported `TelemetryMode` enum (`T
Upload authorization is positive and fail-closed. An unknown direct-construction mode fails before transport configuration is read. Only `FULL` accepts direct `ctx.telemetry.emit()` calls. `FEEDBACK_ONLY` gives its on-demand coordinator a private backend capability and treats only the exact `feedback/record` object already stored at `session.events[event.seq]` as consent; an independently emitted bus value is ignored. `DISABLED` never constructs the SDK pipeline, even when exporter options are present.
The mounted service discloses the resolved mode through the seam's [`TelemetrySharingStatus`](../session-telemetry/README.md#the-sharing-disclosure) `sharing` property (`full` / `feedback-only` / `disabled`), so the `/feedback` acknowledgement can report whether and how the session is shared. The disclosure is set in the constructor and is independent of capture: even `DISABLED` discloses `disabled`.
`exporter.url` is required in `FULL` and `FEEDBACK_ONLY`, has no default, and must parse as `http(s)`; it is optional and unused in `DISABLED`. In uploading modes, `shutdownTimeoutMillis` is a positive finite DSH-owned outer deadline that defaults to 3000 ms, and a non-positive-integer `processor.maxExportBatchSize` also fails at plugin load because the SDK accepts it but then hangs on shutdown. Both SDK blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are SDK behavior tuned through `processor`. The backend implements no `flush()`: the batch processor owns ordinary flushing. During shutdown, OTel awaits `exporter.forceFlush()` before the processor's `exportTimeoutMillis`-bounded completion promise; if that transport promise never settles, this package abandons the wait at `shutdownTimeoutMillis`, logs the contained shutdown failure through the coordinator, and lets application teardown continue. The deadline cannot cancel the SDK transport, so records still pending then may be lost at process exit.
## What leaves the machine

View File

@@ -29,6 +29,8 @@
上传授权采用显式许可,且为 fail-closed。通过直接构造传入未知模式时会在读取传输配置前失败。只有 `FULL` 接受对 `ctx.telemetry.emit()` 的直接调用。`FEEDBACK_ONLY` 向其按需协调器提供私有后端能力,并且仅在 `feedback/record` 对象已经存储于 `session.events[event.seq]` 且对象身份完全相同时,才将其视为同意;独立发出的总线值会被忽略。即使存在导出器选项,`DISABLED` 也绝不会构造 SDK 流水线。
已挂载的服务通过 seam 的 [`TelemetrySharingStatus`](../session-telemetry/README.md#the-sharing-disclosure) `sharing` 属性披露解析后的模式(`full` / `feedback-only` / `disabled`),因此 `/feedback` 的确认文本可以报告会话是否以及如何被共享。该披露在构造函数中设置,与采集相互独立:即使 `DISABLED` 也会披露 `disabled`
`exporter.url``FULL``FEEDBACK_ONLY` 中必填,无默认值,且必须能解析为 `http(s)`;在 `DISABLED` 中可省略且不使用。在上传模式中,`shutdownTimeoutMillis` 是由 DSH 管理的有限正数外层截止时间,默认值为 3000 ms`processor.maxExportBatchSize` 不是正整数时也会在插件加载时失败,因为 SDK 会接受该值,随后却在关闭时挂起。两个 SDK 配置块都整体透传passthrough`OTLPExporterNodeConfigBase` 的每个字段(`headers``timeoutMillis``compression``keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是通过 `processor` 调节的 SDK 行为。该后端不实现 `flush()`:常规 flush 由批处理器负责。关闭期间OTel 会先等待 `exporter.forceFlush()`,再等待受处理器 `exportTimeoutMillis` 限制的完成 promise如果该传输 promise 始终不结算,本包会在 `shutdownTimeoutMillis` 到期时放弃等待,通过协调器记录已隔离的关闭失败,并让应用继续拆卸。该截止时间无法取消 SDK 传输,因此届时仍待处理的记录可能在进程退出时丢失。
## 哪些数据会离开本机

View File

@@ -22,6 +22,7 @@ import {
type TelemetryBackend,
type TelemetryRecord,
type TelemetrySeverity,
type TelemetrySharingStatus,
} from '@deepseek-ai/dsh-session-telemetry'
import { APP_IDENTITY } from '@deepseek-ai/dsh-llm'
import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id'
@@ -71,6 +72,17 @@ function assertNever(value: never): never {
throw new Error(`session-telemetry-otel: unsupported mode ${JSON.stringify(value)}`)
}
/** Map the serialized mode onto the seam's backend-independent sharing vocabulary. */
function sharingStatusFor(mode: TelemetryMode): TelemetrySharingStatus {
switch (mode) {
case TelemetryMode.FULL: return 'full'
case TelemetryMode.FEEDBACK_ONLY: return 'feedback-only'
case TelemetryMode.DISABLED: return 'disabled'
/* v8 ignore next 2 -- resolveMode already rejected unknown values before this switch; the closed enum cannot reach the default. */
default: return assertNever(mode)
}
}
/**
* Plugin configuration: one sharing policy, two verbatim SDK option objects,
* and one DSH-owned shutdown bound. Uploading modes validate their endpoint
@@ -139,10 +151,12 @@ export class TelemetryOtel extends Telemetry {
private readonly directEmit: TelemetryBackend['emit']
private readonly provider: LoggerProvider | undefined
private readonly shutdownTimeoutMillis: number
override readonly sharing: TelemetrySharingStatus
constructor(ctx: Context, config: Config) {
const mode = resolveMode(config.mode)
super(ctx)
this.sharing = sharingStatusFor(mode)
if (mode === TelemetryMode.DISABLED) {
this.directEmit = DROP_RECORD
this.provider = undefined

View File

@@ -364,6 +364,31 @@ describe('TelemetryOtel wire', () => {
expect(captures).toEqual([])
})
it('discloses the sharing policy for every mode', async () => {
const { url, captures } = await mockCollector()
const fullCtx = new Context()
await fullCtx.plugin(SessionStore)
const full = await fullCtx.plugin(TelemetryOtel, { exporter: { url } })
expect(fullCtx.telemetry.sharing).toBe('full')
await full.dispose()
const gatedCtx = new Context()
await gatedCtx.plugin(SessionStore)
const gated = await gatedCtx.plugin(TelemetryOtel, { mode: TelemetryMode.FEEDBACK_ONLY, exporter: { url } })
expect(gatedCtx.telemetry.sharing).toBe('feedback-only')
await gated.dispose()
const disabledCtx = new Context()
await disabledCtx.plugin(SessionStore)
const disabled = await disabledCtx.plugin(TelemetryOtel, { mode: TelemetryMode.DISABLED })
expect(disabledCtx.telemetry.sharing).toBe('disabled')
await disabled.dispose()
// No record was emitted by any mode, so nothing reached the collector.
expect(captures).toEqual([])
})
it('defaults direct construction to full delivery', async () => {
const { url, captures } = await mockCollector()
const ctx = new Context()

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 packages/session/session-telemetry/README.md
README.md: 827554dd53a81eab5a5fd7f145df3f835db9c173
README.zh.md: a350ea5935a2143cb0f876eeb1eb0520ffee5c53
README.md: 707dcfcdb0c8dfbd622630351928ac43562535ec
README.zh.md: bd080adceebf83cd9e53d72a7093db376cf6cbd1

View File

@@ -8,6 +8,12 @@ The telemetry Service Definition declares the `TelemetryBackend` contract, and i
`TelemetryBackend` has three members: `emit(record)` MUST enqueue without blocking because it runs synchronously during `session/event` or explicit canonical-log replay; optional `flush()` is a fire-and-forget hint after a turn ends, and most backends omit it and use their SDK's normal batching schedule; `shutdown()` drains queued records and resolves when the SDK stops, and disposal awaits it. An implementation that provides `flush()` must order concurrent flushes with the final `shutdown()` drain. `Telemetry` registers this API under the `telemetry` context key; each context accepts one implementation, and a duplicate load throws. A backend constructs `TelemetryCoordinator` with `live` or `on-demand` capture and calls `captureSession(session, throughSeq?)` at its chosen trigger.
The service also carries the required [`TelemetrySharingStatus`](#the-sharing-disclosure) `sharing` member: the deployment-selected sharing policy every backend must disclose to human-facing acknowledgement surfaces (the `/feedback` command's confirmation). A consumer renders "not configured" only when no telemetry service is mounted. The seam owns the vocabulary (`full` | `feedback-only` | `disabled`) so any backend can disclose a policy without depending on the OTel package.
## The sharing disclosure
The acknowledgement of a recorded feedback entry reports whether and how the session is shared, read from the mounted backend's `sharing`. A backend sets the property from its deployment configuration: `full` (every event is handed over as it happens), `feedback-only` (nothing is handed over until a `feedback/record` event releases the unreleased prefix through it), or `disabled` (nothing is handed over at all). Consumers map the status onto user-facing copy; the disclosure never claims delivery — handoff is the non-blocking enqueue, and batching, retry, and loss policy stay the backend SDK's.
## Capture points
In `live` mode the coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection from the construction boundary — constructor seeds from fork/resume never re-emit on the firehose and never re-export), `session/event` (project, deep-copy, redact, then hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (capture the session's `shutdown` operational record at its termination edge, then retire it), `agent/error` (the one live-bus relay; the session event vocabulary intentionally has no operational-error record), a dispose effect (capture shutdown for each still-live session, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). In `on-demand` mode it registers only the dispose effect: `captureSession()` reads the canonical log through an optional inclusive sequence boundary, while flush hints and operational events remain local.

View File

@@ -8,6 +8,14 @@
`TelemetryBackend` 有三个成员:`emit(record)` 必须入队且不能阻塞,因为它会在 `session/event` 或显式权威日志回放期间同步执行;可选的 `flush()` 是轮次结束后的提示,调用方不等待结果,多数后端省略它并使用 SDK 的常规批处理计划;`shutdown()` 排空已入队记录,并在 SDK 停止后结束dispose资源释放会等待它。提供 `flush()` 的实现必须安排并发 flush 与 `shutdown()` 最终排空的先后顺序。`Telemetry` 将此 API 注册在 `telemetry` 上下文键下:每个上下文只允许一个实现,重复加载会抛出异常。后端以 `live``on-demand` 捕获构造 `TelemetryCoordinator`,并在自己选择的触发器中调用 `captureSession(session, throughSeq?)`
该服务还携带必需的 [`TelemetrySharingStatus`](#the-sharing-disclosure) `sharing` 成员:每个后端都必须向面向用户的确认 surface`/feedback` 命令的确认文本披露的部署级共享策略。消费方只有在未挂载任何遥测服务时才渲染「未配置」。seam 拥有该词汇(`full` | `feedback-only` | `disabled`),因此任何后端都可以披露策略,而无需依赖 OTel 包。
<a id="the-sharing-disclosure"></a>
## 共享披露
一条已记录的反馈条目的确认文本会报告该会话是否以及如何被共享,读取自已挂载后端的 `sharing`。后端根据其部署配置设置该属性:`full`(每个事件在发生时立即交接)、`feedback-only`(在 `feedback/record` 事件释放其之前的未释放前缀之前,不交接任何内容)或 `disabled`(完全不交接任何内容)。消费方把状态映射为面向用户的文案;披露从不声称投递——交接是非阻塞入队,批处理、重试与丢失策略仍归后端 SDK。
## 捕获点
`live` 模式中,协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header并经投影从构造边界起回读日志来自 fork 或恢复的构造函数种子绝不会在 firehose 上再次发出,也绝不会再次导出)、`session/event`(投影、深拷贝、脱敏,再交接;零 I/O`session/flush`(转发可选的 `flush()` 提示并返回 void循环所等待的并行任务绝不能等待遥测`session/disposed`(在会话自身的终止边缘捕获该会话的 `shutdown` 运维记录,然后将其退役)、`agent/error`(唯一的实时总线转发;会话事件词汇有意不包含运维错误记录)、一个 dispose effect捕获每个仍存活会话的 shutdown再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。在 `on-demand` 模式中,协调器只注册 dispose effect`captureSession()` 读取权威日志直至可选的序列号边界含边界flush 提示与运维事件留在本地。

View File

@@ -130,6 +130,15 @@ export interface TelemetryBackend {
shutdown(): Promise<void>
}
/**
* Deployment-selected session-sharing policy disclosed by a mounted
* {@link Telemetry} backend to human-facing acknowledgement surfaces (the
* `/feedback` command's confirmation text). The seam owns the vocabulary so
* any backend can disclose a policy without depending on the OTel package;
* the values mirror the OTel backend's serialized `TelemetryMode` choices.
*/
export type TelemetrySharingStatus = 'full' | 'feedback-only' | 'disabled'
/**
* Loadable form of the backend contract: one implementation per context —
* the cordis `Service` registration under the `telemetry` key throws on a
@@ -141,6 +150,15 @@ export abstract class Telemetry extends Service implements TelemetryBackend {
super(ctx, 'telemetry')
}
/**
* Deployment-selected session-sharing policy, disclosed for acknowledgement
* surfaces that report whether recorded feedback leaves the process. Every
* backend must disclose its policy; a consumer renders "not configured" only
* when no telemetry service is mounted. The seam owns this vocabulary so the
* disclosure is backend-independent.
*/
abstract readonly sharing: TelemetrySharingStatus
/**
* See {@link TelemetryBackend.emit} — that declaration is the contract's one home.
* @param record - the logical record to report; owned by the backend after the call.