diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 36d085e10a..e44344f777 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -54,8 +54,10 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`@openai/codex`](https://github.com/openai/codex) | Apache-2.0 | | [`@opentelemetry/api`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@opentelemetry/api-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | +| [`@opentelemetry/core`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@opentelemetry/exporter-logs-otlp-http`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@opentelemetry/otlp-exporter-base`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | +| [`@opentelemetry/otlp-transformer`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@opentelemetry/resources`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@opentelemetry/sdk-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@playwright/mcp`](https://github.com/microsoft/playwright-mcp) | Apache-2.0 | diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 38aae3332c..2f89559d39 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -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 apps/desktop/README.md -README.md: 2247f79c839da78f997a80ba73420d0ab42cb926 -README.zh.md: 8ddb461c44191ac936e70064f78871063db67e64 +README.md: 4de8eb539bba42919fd29740bb2741566f460ab1 +README.zh.md: 9e733caae70fc3fe4d0131548a14660cddbd2248 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 2247f79c83..4de8eb539b 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -162,7 +162,7 @@ Desktop packs workspace packages locally and installs external dependencies thro The [Office conversion provider](../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. Complete native engine packages, including executables, libraries, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/@deepseek-ai/`. The Desktop Host resolves engine manifests to these physical directories so native subprocesses can read their resources; the JavaScript API and WASM engine remain in ASAR. On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in). -The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](scripts/smoke-runtime.ts) creates DOCX, XLSX, and PPTX inputs with bundled Python, converts each through the real Office provider, and checks PDF output. Every assembled application, including directory and unsigned Windows builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization. +The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. The payload smoke resolves the search tool’s ripgrep executable and verifies text search and file enumeration. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](scripts/smoke-runtime.ts) creates DOCX, XLSX, and PPTX inputs with bundled Python, converts each through the real Office provider, and checks PDF output. Every assembled application, including directory and unsigned Windows builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization. Windows release qualification also runs [directory and replacement checks](scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory. From the repository root, run the command below. It verifies directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 8ddb461c44..9e733caae7 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -162,7 +162,7 @@ Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安 [Office 转换提供方](../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。完整原生引擎包(包括可执行文件、库、数据、许可证和 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/@deepseek-ai/` 下。Desktop Host 将引擎清单解析到这些物理目录,使原生子进程能读取资源;JavaScript API 和 WASM 引擎保留在 ASAR 中。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。 -打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。[Host smoke](scripts/smoke-runtime.ts) 使用捆绑的 Python 创建 DOCX、XLSX 和 PPTX 输入,通过真实 Office 提供方逐一转换并检查 PDF 输出。每个组装后的应用(包括目录包和 Windows 未签名构建)都会针对 ASAR 重复产物和 Host 检查。归档完整性检查将归档内完整描述符与准备结果比对,并核对归档和解包目录中的文件内容与清单、归档内文件记录的执行标志,以及解包文件的物理权限。转换失败会在写入发布记录前终止打包;macOS DMG/ZIP 构建在公证前执行这些检查。 +打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。产物 smoke 解析搜索工具使用的 ripgrep 可执行文件,并验证文本搜索和文件枚举。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。[Host smoke](scripts/smoke-runtime.ts) 使用捆绑的 Python 创建 DOCX、XLSX 和 PPTX 输入,通过真实 Office 提供方逐一转换并检查 PDF 输出。每个组装后的应用(包括目录包和 Windows 未签名构建)都会针对 ASAR 重复产物和 Host 检查。归档完整性检查将归档内完整描述符与准备结果比对,并核对归档和解包目录中的文件内容与清单、归档内文件记录的执行标志,以及解包文件的物理权限。转换失败会在写入发布记录前终止打包;macOS DMG/ZIP 构建在公证前执行这些检查。 Windows 发布验收还需在 Desktop 构建后手动运行[目录和替换检查](scripts/smoke-windows.ps1)。将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录。从仓库根目录运行以下命令。它验证 目录替换与回滚和两种文件占用替换方式;不属于单元测试通道。 diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index 118bae070b..4ecb4cc759 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -68,7 +68,7 @@ export function createElectronBuilderConfig( let primaryRuntimeDestination let dshDestination let windowsCode = [] - const unpack = ['**/*.{node,dylib,dll,so,exe}', '**/*.so.*', '**/spawn-helper', '**/@vscode/ripgrep/bin/rg', + const unpack = ['**/*.{node,dylib,dll,so,exe}', '**/*.so.*', '**/spawn-helper', '**/@vscode/ripgrep-*/bin/rg', `**/node_modules/@deepseek-ai/libreoffice-kit-${resolvedPlatform}-${resolvedArch}/**/*`] const windowsSigner = packagesWindows && !unsigned ? createWindowsTokenSigner({ diff --git a/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs b/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs index bb015d3dff..d0d7bb94f6 100644 --- a/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs +++ b/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs @@ -7,6 +7,7 @@ import { rm } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { delimiter, dirname, join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' const runtime = process.argv[2] assert.ok(runtime, 'Pass the filtered resources/dsh directory') @@ -98,6 +99,21 @@ async function checkPty() { } } +/** Exercise grep and glob operations with the search tool's resolved native executable. */ +async function checkSearch() { + const { resolveRgPath } = await import(pathToFileURL(requireRuntime.resolve('@deepseek-ai/dsh-tool-fs-search')).href) + const executable = await resolveRgPath() + const name = 'ripgrep-smoke.txt' + const marker = 'desktop-ripgrep-smoke' + writeFileSync(join(scratch, name), `${marker}\n`, { flag: 'wx', mode: 0o600 }) + const run = args => execFileSync(executable, ['--no-config', ...args], { + cwd: scratch, encoding: 'utf8', timeout: 45_000, windowsHide: true, + env: Object.fromEntries(Object.entries(process.env).filter(([name]) => /^(?:systemroot|windir)$/iu.test(name))), + }).trim().replaceAll('\\', '/') + assert.equal(run(['--no-heading', '--no-filename', '--line-number', '--fixed-strings', '--', marker, name]), `1:${marker}`) + assert.equal(run(['--files', '--glob', name, '.']), `./${name}`) +} + /** Resolve one system function through Koffi's packaged native module. */ function checkKoffi() { const koffi = requireRuntime('koffi') @@ -146,6 +162,7 @@ try { await checkSharp() checkHtml() await checkPty() + await checkSearch() } finally { // This private tree contains only fixture files; Windows may release handles after terminal exit. await rm(scratch, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }) @@ -154,5 +171,5 @@ try { // Natural event-loop drain includes node-pty's worker and console-list helper teardown. process.once('beforeExit', () => { console.log(JSON.stringify({ node: process.versions.node, platform: process.platform, arch: process.arch, - koffi: true, sharp: true, html: true, pty: true, pnpm: true })) + koffi: true, sharp: true, html: true, pty: true, pnpm: true, grep: true, glob: true })) }) diff --git a/apps/desktop/tests/macos-signature.spec.ts b/apps/desktop/tests/macos-signature.spec.ts index 6cd8faeccc..75891f0183 100644 --- a/apps/desktop/tests/macos-signature.spec.ts +++ b/apps/desktop/tests/macos-signature.spec.ts @@ -56,7 +56,7 @@ describe('desktop macOS release signature', () => { expect(dshNodeModules.to).toBe('dsh/node_modules') expect(config.asarUnpack).toEqual(expect.arrayContaining([ '**/*.{node,dylib,dll,so,exe}', - '**/@vscode/ripgrep/bin/rg', + '**/@vscode/ripgrep-*/bin/rg', ])) expect(config).toMatchObject({ appId: RELEASE_ENVIRONMENT.DSH_DESKTOP_APP_ID, diff --git a/apps/desktop/tests/windows-asar-unpack.spec.ts b/apps/desktop/tests/windows-asar-unpack.spec.ts index 5cbe410105..1be1eb6266 100644 --- a/apps/desktop/tests/windows-asar-unpack.spec.ts +++ b/apps/desktop/tests/windows-asar-unpack.spec.ts @@ -46,6 +46,14 @@ afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) +function unsignedWindowsConfig(appId: string, source: string) { + return createElectronBuilderConfig({ + DSH_DESKTOP_APP_ID: appId, DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', + DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }), + DSH_DESKTOP_UNSIGNED: '1', + }, 'win32', 'x64', source) +} + async function fixture(external: boolean) { const root = await mkdtemp(join(tmpdir(), 'windows-asar-')) roots.push(root) @@ -188,6 +196,27 @@ it.each([true, false])('validates the real builder hook for unsigned=%s', async } }) +it.each([false, true])('unpacks platform ripgrep executables with external source=%s', async (external) => { + const input = await fixture(external) + // Windows rg.exe uses the general executable rule; macOS rg uses the platform-package rule. + const files = ['ripgrep-darwin-arm64/bin/rg', 'ripgrep-darwin-x64/bin/rg', 'ripgrep-win32-x64/bin/rg.exe'] + for (const file of [...files, 'ripgrep/lib/index.js']) { + const path = join(input.source, 'node_modules', '@vscode', file) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, 'ripgrep fixture') + } + const config = unsignedWindowsConfig('com.example.ripgrep', input.source) + input.config.asarUnpack = [...config.asarUnpack] + await packageFixture(input) + const archive = await readAsar(join(input.resources, 'app.asar')) + expect(archive.getFile(join('dsh', 'node_modules', '@vscode', 'ripgrep/lib/index.js')).unpacked).not.toBe(true) + for (const file of files) { + const path = join('dsh', 'node_modules', '@vscode', file) + expect(archive.getFile(path, false).unpacked).toBe(true) + expect(await readFile(join(input.resources, 'app.asar.unpacked', path), 'utf8')).toBe('ripgrep fixture') + } +}) + it.each([false, true])('keeps the complete Office engine outside ASAR with external source=%s', async (external) => { const input = await fixture(external) const engine = join('node_modules', '@deepseek-ai', 'libreoffice-kit-win32-x64') @@ -200,11 +229,7 @@ it.each([false, true])('keeps the complete Office engine outside ASAR with exter const wasm = join(input.source, 'node_modules/@deepseek-ai/libreoffice-kit-wasm/package.json') await mkdir(dirname(wasm), { recursive: true }) await writeFile(wasm, '{}') - const config = createElectronBuilderConfig({ - DSH_DESKTOP_APP_ID: 'com.example.office', DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', - DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }), - DSH_DESKTOP_UNSIGNED: '1', - }, 'win32', 'x64', input.source) + const config = unsignedWindowsConfig('com.example.office', input.source) input.config.asarUnpack = [...config.asarUnpack] await config.beforePack(input.context) await packageFixture(input) diff --git a/apps/web/package.json b/apps/web/package.json index b84d06a2dc..baf572840b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,6 +34,7 @@ "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", "@deepseek-ai/dsh-client-ui-dockkit": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", diff --git a/apps/web/tests/changed-files-turn.e2e.ts b/apps/web/tests/changed-files-turn.e2e.ts index a3691df3c2..369c0e870e 100644 --- a/apps/web/tests/changed-files-turn.e2e.ts +++ b/apps/web/tests/changed-files-turn.e2e.ts @@ -5,8 +5,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { chromium, type Browser, type Page } from 'playwright' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFinished } from 'vitest' import type {} from '@deepseek-ai/dsh-workspace-changes' +import type { ChangesSummary } from '@deepseek-ai/dsh-client-ui-deliverables/src/changes.ts' import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import { assertFinalWorkspaceSnapshot, captureExpandedTurnProcessAria, compareOrRefreshGolden, @@ -80,9 +81,10 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { }) await seedRepository(join(scaffold.workspaceCwd, 'workspace')) browser = await chromium.launch() - page = await browser.newPage({ + const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE, timezoneId: 'Asia/Shanghai', }) + page = await context.newPage() tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]') @@ -307,6 +309,40 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { return [center(text), center(caret), center(counts)].map(value => Math.abs(value - buttonCenter)) }) for (const offset of headerAlignment) expect(offset).toBeLessThanOrEqual(0.5) + // The appended row has no old-side text; constrain the columns so the hunk header overflows it. + const emptyRowLayout = await page.addStyleTag({ content: ` + [data-diff-side="left"] { width: 90px; } + [data-diff-side="right"] { width: 120px; } + ` }) + try { + const left = review.locator('[data-diff-side="left"]') + const emptyRow = left.locator('[data-diff-line="add"]') + const maximum = await left.evaluate(element => element.scrollWidth - element.clientWidth) + expect(maximum).toBeGreaterThan(0) + await left.evaluate((element) => { element.scrollLeft = element.scrollWidth }) + await expect.poll(() => left.evaluate(element => element.scrollLeft)).toBe(maximum) + const fill = await emptyRow.evaluate((row) => { + const column = row.closest('[data-diff-side]')! + const columnBox = column.getBoundingClientRect() + const rowBox = row.getBoundingClientRect() + const y = rowBox.top + rowBox.height / 2 + return { + rowWidth: rowBox.width, + contentWidth: column.scrollWidth, + background: getComputedStyle(row).backgroundColor, + coversViewport: [columnBox.left + 4, columnBox.right - 4].every(x => + document.elementFromPoint(x, y)?.closest('[data-diff-line]') === row), + } + }) + expect(fill.background).not.toBe('rgba(0, 0, 0, 0)') + expect(fill.rowWidth).toBeGreaterThanOrEqual(fill.contentWidth - 0.5) + expect(fill.coversViewport).toBe(true) + } finally { + await emptyRowLayout.evaluate(element => element.parentNode!.removeChild(element)) + await review.locator('[data-diff-side]').evaluateAll((elements) => { + for (const element of elements) element.scrollLeft = 0 + }) + } const addedLine = review.locator('[data-diff-side="right"] [data-diff-line="add"]') const rightContextLine = review.locator('[data-diff-side="right"] [data-diff-line="context"]') const addedRule = await addedLine.evaluate((line) => { @@ -446,9 +482,154 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { expect(tripwire.warnings).toEqual([]) }) + it('keeps edge gestures inside either diff column and accepts reverse scrolling', async () => { + const review = page.locator('[data-changes-review]') + await review.getByRole('button', { name: '自动换行' }).click() + const body = review.locator('[data-review-view="split"]:not([data-review-wrap])') + await body.waitFor({ state: 'visible' }) + // A scrollable parent makes escaped edge gestures observable without native trackpad rebound. + const layout = await page.addStyleTag({ content: ` + [data-changes-review] { height: 220px !important; } + [data-review-view="split"] { display: block; max-width: 220px; } + [data-review-view="split"] > div { width: 160px; height: 60px; margin: 160px; } + ` }) + onTestFinished(async () => { + await layout.evaluate(element => element.parentNode!.removeChild(element)) + await body.evaluate((element) => { element.scrollTo(0, 0) }) + await review.getByRole('button', { name: '自动换行' }).click() + }) + const input = await page.context().newCDPSession(page) + try { + for (const side of ['left', 'right']) { + const column = review.locator(`[data-diff-side="${side}"]`) + for (const axis of ['x', 'y'] as const) { + for (const end of [false, true]) { + const parentPosition = await body.evaluate((element) => { + const x = (element.scrollWidth - element.clientWidth) / 2 + const y = (element.scrollHeight - element.clientHeight) / 2 + element.scrollTo(x, y) + return { x: element.scrollLeft, y: element.scrollTop } + }) + expect(parentPosition.x).toBeGreaterThan(0) + expect(parentPosition.y).toBeGreaterThan(0) + const edge = await column.evaluate((element, { axis, end }) => { + const maximum = axis === 'x' + ? element.scrollWidth - element.clientWidth + : element.scrollHeight - element.clientHeight + element.scrollTo(axis === 'x' && end ? maximum : 0, axis === 'y' && end ? maximum : 0) + return { maximum, offset: end ? maximum : 0 } + }, { axis, end }) + expect(edge.maximum).toBeGreaterThan(0) + const position = () => column.evaluate((element, axis) => axis === 'x' ? element.scrollLeft : element.scrollTop, axis) + await expect.poll(position).toBe(edge.offset) + const box = await column.boundingBox() + if (box === null) throw new Error('diff column has no visible bounds') + const gesture = { x: box.x + box.width / 2, y: box.y + box.height / 2, gestureSourceType: 'mouse' as const } + const distance = end ? -80 : 80 + // CDP acknowledges the completed gesture, including default scrolling, before observation. + await input.send('Input.synthesizeScrollGesture', { + ...gesture, xDistance: axis === 'x' ? distance : 0, yDistance: axis === 'y' ? distance : 0, + }) + expect(await body.evaluate(element => ({ x: element.scrollLeft, y: element.scrollTop }))).toEqual(parentPosition) + expect(await position()).toBe(edge.offset) + await input.send('Input.synthesizeScrollGesture', { + ...gesture, xDistance: axis === 'x' ? -distance : 0, yDistance: axis === 'y' ? -distance : 0, + }) + await expect.poll(position).not.toBe(edge.offset) + } + } + } + } finally { + await input.detach() + } + }) + it.skipIf(MODE === 'record')('replays the workspace and the Chinese conversation', async () => { await assertFinalWorkspaceSnapshot(DIR, cwd, { ignoredRootEntries: ['.git'] }) const aria = await captureExpandedTurnProcessAria(page, '[data-chat-flow]', scaffold.workspaceCwd) await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE) }) + + it('preserves filename endings when the review narrows and updates the fade after resizing or selecting another file', async () => { + const preview = await page.context().newPage() + onTestFinished(async () => { await preview.close() }) + const previewTripwire = watchConsole(preview) + const prefix = 'src/components/review/' + 'long-filename-'.repeat(8) + const names = [`${prefix}before.ts`, `${prefix}after.ts`] as const + // Project long display names into a separate page; recorded paths, comparisons, and Session data stay intact. + await preview.route('**/api/changes.summary?*', async (route) => { + const response = await route.fetch() + const summary = await response.json() as ChangesSummary + await route.fulfill({ response, json: { + ...summary, files: summary.files.map((file, index) => ({ ...file, display: names[index] ?? file.display })), + } }) + }) + await preview.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await preview.locator('[data-changed-files]').getByRole('button', { name: '在侧边栏查看本轮改动' }).click() + const review = preview.locator('[data-changes-review]') + const selector = review.getByRole('button', { name: '选择要查看的文件' }) + const label = selector.locator('[data-path-label]') + const layout = await preview.addStyleTag({ content: '[data-sidebar-right-panel] { width: 340px !important; }' }) + const resize = async (width: number) => { + await layout.evaluate((element, width) => { + element.textContent = `[data-sidebar-right-panel] { width: ${width}px !important; }` + }, width) + } + const metrics = () => label.evaluate((element) => { + const text = element.firstElementChild! + const box = element.getBoundingClientRect() + const textBox = text.getBoundingClientRect() + const filename = text.lastElementChild! + const suffix = document.createRange() + const node = filename.firstChild! + suffix.setStart(node, Math.max(0, node.textContent!.length - 'before.ts'.length)) + suffix.setEnd(node, node.textContent!.length) + const suffixBox = suffix.getBoundingClientRect() + return { + clipped: element.hasAttribute('data-path-clipped'), mask: getComputedStyle(element).maskImage, + left: textBox.left - box.left, right: box.right - textBox.right, + suffixVisible: suffixBox.left >= box.left && suffixBox.right <= box.right + 0.5, + directoryColor: getComputedStyle(text.firstElementChild!).color, + nameColor: getComputedStyle(filename).color, + } + }) + await expect.poll(async () => (await metrics()).clipped).toBe(true) + const clipped = await metrics() + expect(clipped.left).toBeLessThan(0) + expect(Math.abs(clipped.right)).toBeLessThanOrEqual(0.5) + expect(clipped.suffixVisible).toBe(true) + expect(clipped.mask).toContain('linear-gradient') + expect(clipped.directoryColor).not.toBe(clipped.nameColor) + expect(await label.getAttribute('title')).toBe(names[0]) + const controls = await selector.evaluate((button) => { + const header = button.parentElement!.parentElement! + const bounds = header.getBoundingClientRect() + const caret = button.querySelector('svg')! + const counts = button.parentElement!.nextElementSibling! + return [caret, counts, ...header.querySelectorAll('[data-review-tool]')].map((element) => { + const box = element.getBoundingClientRect() + return box.width > 0 && box.left >= bounds.left && box.right <= bounds.right + }) + }) + expect(controls.every(Boolean)).toBe(true) + await selector.click() + await preview.getByRole('menuitem').filter({ hasText: names[1] }).click() + await expect.poll(() => label.getAttribute('title')).toBe(names[1]) + expect((await metrics()).suffixVisible).toBe(true) + await resize(1400) + await expect.poll(async () => (await metrics()).clipped).toBe(false) + expect((await metrics()).left).toBeCloseTo(0, 1) + expect((await metrics()).mask).toBe('none') + await resize(340) + await expect.poll(async () => (await metrics()).clipped).toBe(true) + await selector.click() + await preview.getByRole('menuitem').filter({ hasText: 'src/util.ts' }).click() + await expect.poll(() => label.getAttribute('title')).toBe('src/util.ts') + await expect.poll(async () => (await metrics()).clipped).toBe(false) + expect((await metrics()).left).toBeCloseTo(0, 1) + expect((await metrics()).mask).toBe('none') + expect(previewTripwire.pageErrors).toEqual([]) + expect(previewTripwire.warnings).toEqual([]) + }) + }) diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 73405ef455..6a71bb9da7 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -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/capability-seams.md -capability-seams.md: 66425afe98bcbefe69912bf130cb72bba9435ec0 -capability-seams.zh.md: faa31be64081a80d58a0f302f91c737c34ed3f38 +capability-seams.md: 072cb47af420136a08ce182cfa341bb42fc068e0 +capability-seams.zh.md: 138fb3241a00cb2f6ab8853dd9cbf1440fe6d490 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 66425afe98..072cb47af4 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -3,7 +3,7 @@ # Capability Seams And Core Services -A service can be a core spine service, a swappable capability seam, or a bundle/composition point. The graph shows the package that owns the service declaration, known implementation packages, and packages that consume the service directly. +A service can be a core spine service, a swappable capability seam, a bundle/composition point, or a standalone service. The graph shows the package that owns the service declaration, known implementation packages, and packages that consume the service directly. ```mermaid flowchart LR @@ -104,6 +104,8 @@ flowchart LR pkg_credentials_local["credentials-local"] pkg_authorization["authorization"] svc_authorization["ctx.authorization
Authorization flow registry"] + pkg_host_product_telemetry_otel["host-product-telemetry-otel"] + svc_productTelemetry["ctx.productTelemetry
Product usage event sender"] pkg_session_telemetry["session-telemetry"] svc_sessionTelemetry["ctx.sessionTelemetry
Session telemetry seam"] pkg_session_telemetry_otel["session-telemetry-otel"] @@ -318,6 +320,7 @@ flowchart LR pkg_host_directory_picker --> svc_directoryPicker pkg_host_directory_picker_browse --> svc_directoryPicker pkg_host_directory_picker_native --> svc_directoryPicker + pkg_host_product_telemetry_otel --> svc_productTelemetry pkg_host_webserver --> svc_webServer pkg_inspector --> svc_inspector pkg_invariants --> svc_invariants @@ -586,6 +589,7 @@ flowchart LR | `ctx.subagentModelSelection` | `core` | [`tool-subagent`](../packages/subagent/tool-subagent) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Owns the default-off settings namespace that Agent-scoped delegation tools sample when composing a new top-level Session. | | `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the settings controller exposes value-free views and write-only storage. | | `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Flows are registered by the plugin that knows how to obtain one credential and keyed by the record they write; the seam owns the conversation and the one-attempt-per-key lifecycle, never the protocol. | +| `ctx.productTelemetry` | `service` | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | - | - | - | Exports explicitly submitted analytics events through OTLP/HTTP; mounting alone collects nothing. | | `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. | | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index faa31be640..138fb3241a 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -5,7 +5,7 @@ [English](capability-seams.md) | 中文 -服务可以是核心主干服务、可替换的能力 seam,也可以是组合包/组合点。下图展示了拥有服务声明的包、已知实现包,以及直接消费该服务的包。 +服务可以是核心主干服务、可替换的能力 seam、组合包/组合点或独立服务。下图展示了拥有服务声明的包、已知实现包,以及直接消费该服务的包。 ```mermaid flowchart LR @@ -106,6 +106,8 @@ flowchart LR pkg_credentials_local["credentials-local"] pkg_authorization["authorization"] svc_authorization["ctx.authorization
Authorization flow registry"] + pkg_host_product_telemetry_otel["host-product-telemetry-otel"] + svc_productTelemetry["ctx.productTelemetry
Product usage event sender"] pkg_session_telemetry["session-telemetry"] svc_sessionTelemetry["ctx.sessionTelemetry
Session telemetry seam"] pkg_session_telemetry_otel["session-telemetry-otel"] @@ -320,6 +322,7 @@ flowchart LR pkg_host_directory_picker --> svc_directoryPicker pkg_host_directory_picker_browse --> svc_directoryPicker pkg_host_directory_picker_native --> svc_directoryPicker + pkg_host_product_telemetry_otel --> svc_productTelemetry pkg_host_webserver --> svc_webServer pkg_inspector --> svc_inspector pkg_invariants --> svc_invariants @@ -588,6 +591,7 @@ flowchart LR | `ctx.subagentModelSelection` | `core` | [`tool-subagent`](../packages/subagent/tool-subagent) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | - | 拥有默认关闭的设置命名空间;Agent 作用域的委派工具会在组合新顶层 Session 时读取它。 | | `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;settings controller 提供不含实际值的视图和只写存储。 | | `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | flow 由知道如何取得某份凭据的插件注册,并以其写入的记录为键;seam 拥有这段对话与"每个键同时只跑一次尝试"的生命周期,而非协议本身。 | +| `ctx.productTelemetry` | `service` | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | - | - | - | 通过 OTLP/HTTP 发送显式提交的分析事件;仅挂载插件不会采集信息。 | | `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | 该 seam 捕获会话记录、进行脱敏并交给一个后端;没有其他组件消费该服务,其输出会离开当前进程。 | | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | 各后端以不同名称并列注册;数据形态(领域优先)挂载到枢纽上,并将类型化操作转换为不透明的 KV 单元原语。 | | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | 等待所有已配置后端就绪,然后将领域形态发布为一个受生命周期约束的服务,用于类型化持久状态。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 805da9758c..4210a71a33 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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/config-catalog.md -config-catalog.md: 32faec64e3ece1053430219c6be57a7044308f5a -config-catalog.zh.md: 08a7b368e0332b917de1f627a22415ede4d18f36 +config-catalog.md: 2225721f4adf3e938da7fa74a3b1cb2b159b0ed4 +config-catalog.zh.md: 21e95ea2a3bbbcf3f4667dd3f26fc10e6f607d7b diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 32faec64e3..2225721f4a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1208,6 +1208,40 @@ export interface Config { Source: [`packages/host/open-in-app/src/index.ts:50`](../packages/host/open-in-app/src/index.ts) + + +## `@deepseek-ai/dsh-host-product-telemetry-otel` + +```ts config-catalog +/** Collector routing, application identity, and bounded in-memory batch settings. */ +export interface Config { + /** Full HTTP(S) logs URL. */ + endpoint: string + /** Collector routing header. */ + channel: string + /** Resource service.name supplied by the application composition. */ + serviceName: string + /** Resource service.version supplied by the application composition. */ + serviceVersion: string + /** Omit to honor OTEL_EXPORTER_OTLP_LOGS_COMPRESSION / OTEL_EXPORTER_OTLP_COMPRESSION. */ + compression?: 'none' | 'gzip' + /** Maximum records per export; must not exceed maxQueueSize. */ + maxExportBatchSize: number + /** Maximum queued records; the SDK drops new records when full. */ + maxQueueSize: number + /** Delay before exporting a partial batch. */ + scheduledDelayMillis: number + /** Exporter HTTP deadline, including SDK transient-error retries. */ + timeoutMillis: number + /** Processor deadline for one batch export. */ + exportTimeoutMillis: number + /** Outer shutdown wait; pending exports may be lost after this deadline. */ + shutdownTimeoutMillis: number +} +``` + +Source: [`packages/host/product-telemetry-otel/src/index.ts:37`](../packages/host/product-telemetry-otel/src/index.ts) + ## `@deepseek-ai/dsh-host-webserver` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 08a7b368e0..21e95ea2a3 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1212,6 +1212,40 @@ export interface Config { 来源:[`packages/host/open-in-app/src/index.ts:50`](../packages/host/open-in-app/src/index.ts) + + +## `@deepseek-ai/dsh-host-product-telemetry-otel` + +```ts config-catalog +/** Collector routing, application identity, and bounded in-memory batch settings. */ +export interface Config { + /** Full HTTP(S) logs URL. */ + endpoint: string + /** Collector routing header. */ + channel: string + /** Resource service.name supplied by the application composition. */ + serviceName: string + /** Resource service.version supplied by the application composition. */ + serviceVersion: string + /** Omit to honor OTEL_EXPORTER_OTLP_LOGS_COMPRESSION / OTEL_EXPORTER_OTLP_COMPRESSION. */ + compression?: 'none' | 'gzip' + /** Maximum records per export; must not exceed maxQueueSize. */ + maxExportBatchSize: number + /** Maximum queued records; the SDK drops new records when full. */ + maxQueueSize: number + /** Delay before exporting a partial batch. */ + scheduledDelayMillis: number + /** Exporter HTTP deadline, including SDK transient-error retries. */ + timeoutMillis: number + /** Processor deadline for one batch export. */ + exportTimeoutMillis: number + /** Outer shutdown wait; pending exports may be lost after this deadline. */ + shutdownTimeoutMillis: number +} +``` + +来源: [`packages/host/product-telemetry-otel/src/index.ts:37`](../packages/host/product-telemetry-otel/src/index.ts) + ## `@deepseek-ai/dsh-host-webserver` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 1cea8df504..b3adeb8c42 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -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/module-graph.md -module-graph.md: 2e22c198c29bfba5eaa1a548af3bb7a9f190d0c8 -module-graph.zh.md: ce5c445c01b197c260d41af1d991b10fe799873a +module-graph.md: ae55f2bb2cf29f0f6f4ad3c16df9abb7a3511191 +module-graph.zh.md: cef0a09afb07c4f8ee5392899a1cee3f9cadf3ca diff --git a/docs/module-graph.md b/docs/module-graph.md index 2e22c198c2..ae55f2bb2c 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -277,6 +277,7 @@ flowchart TD pkg_host_frontend_static["host-frontend-static"] pkg_host_open_in_app["host-open-in-app"] pkg_host_plugin_inventory["host-plugin-inventory"] + pkg_host_product_telemetry_otel["host-product-telemetry-otel"] pkg_host_webserver["host-webserver"] end subgraph group_identity["packages/identity"] @@ -1427,6 +1428,7 @@ flowchart TD | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | — | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | — | | [`host-open-in-app`](../packages/host/open-in-app) | `host` | — | +| [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | — | | [`host-webserver`](../packages/host/webserver) | `host` | — | | [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — | | [`session-format`](../packages/session/session-format) | `session` | — | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index ce5c445c01..cef0a09afb 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -279,6 +279,7 @@ flowchart TD pkg_host_frontend_static["host-frontend-static"] pkg_host_open_in_app["host-open-in-app"] pkg_host_plugin_inventory["host-plugin-inventory"] + pkg_host_product_telemetry_otel["host-product-telemetry-otel"] pkg_host_webserver["host-webserver"] end subgraph group_identity["packages/identity"] @@ -1429,6 +1430,7 @@ flowchart TD | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | — | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | — | | [`host-open-in-app`](../packages/host/open-in-app) | `host` | — | +| [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | — | | [`host-webserver`](../packages/host/webserver) | `host` | — | | [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — | | [`session-format`](../packages/session/session-format) | `session` | — | diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index af12f99e97..fbe468ab96 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/README.i18n.yaml @@ -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/README.md -README.md: db21e0fe6bea366c4d7efc1d96d247dd255f54da -README.zh.md: 4b2b28dbf9a39da0c0ce3a062c4de3261501f7d9 +README.md: d2e8dea206e24ae65676ae50596a3f559239cc21 +README.zh.md: 78c97a016a93415ac1fca1afb6658aa623a61f7a diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index db21e0fe6b..d2e8dea206 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -67,5 +67,6 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [conversation.md](conversation.md) | target-neutral Session-event assembly: Context identity, Location data, replay paths, view builders, and target-owned render nodes | | [session-projection.md](session-projection.md) | the projection seam: `SessionProjectionMap`, the pure `ProjectionDefinition` unit, `ProjectionSnapshot`'s consistent cut, the change feed | | [session-telemetry.md](session-telemetry.md) | the outbound session-reporting capability seam: `SessionTelemetryRecord`/`SessionTelemetrySeverity`, the `SessionTelemetrySink` contract, and the `session-telemetry/record` redact waterfall | +| [product-telemetry.md](product-telemetry.md) | Explicit product analytics submission and OTLP/HTTP transport | > Type declarations and their JSDoc on these pages are source-equivalent and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Ordinary blocks preserve complete declarations; `public-api` blocks preserve body-stripped public class declarations. Cordis services and events use each page's generated **Cordis API** section. diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index 4b2b28dbf9..78c97a016a 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -67,5 +67,6 @@ | [conversation.md](conversation.zh.md) | 目标无关的会话事件组装:上下文标识、位置数据、回放路径、视图构建器与目标自有的渲染节点 | | [session-projection.md](session-projection.zh.md) | 投影 seam:`SessionProjectionMap`、纯函数 `ProjectionDefinition` 单元、`ProjectionSnapshot` 的一致切面、变更馈送 | | [session-telemetry.md](session-telemetry.zh.md) | 对外会话上报能力 seam:`SessionTelemetryRecord`/`SessionTelemetrySeverity`、`SessionTelemetrySink` 约定和 `session-telemetry/record` 脱敏 waterfall(瀑布式事件) | +| [product-telemetry.md](product-telemetry.zh.md) | 显式产品分析事件提交与 OTLP/HTTP 传输 | > 这些页面上的类型声明及其 JSDoc 与源码等价,并由 `pnpm run verify-type-equiv` 检查漂移(见 [development.md](../development.zh.md#documenting-types-verbatim-ts-type-equiv))。普通块保留完整声明;`public-api` 块保留去除实现体的公开 class 声明。Cordis 服务与事件使用每页生成的 **Cordis API** 小节。 diff --git a/docs/subsystems/product-telemetry.i18n.yaml b/docs/subsystems/product-telemetry.i18n.yaml new file mode 100644 index 0000000000..e5e8b34aea --- /dev/null +++ b/docs/subsystems/product-telemetry.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/product-telemetry.md +product-telemetry.md: d86540d5a2b75d84f11120b80b7d107788799238 +product-telemetry.zh.md: 52ad1193630fc67f87c9c710027fc1ec5a5db98a diff --git a/docs/subsystems/product-telemetry.md b/docs/subsystems/product-telemetry.md new file mode 100644 index 0000000000..d86540d5a2 --- /dev/null +++ b/docs/subsystems/product-telemetry.md @@ -0,0 +1,60 @@ +# Product telemetry + +English | [中文](product-telemetry.zh.md) + +The [product telemetry plugin](../../packages/host/product-telemetry-otel/README.md) sends explicitly selected analytics events through OTLP/HTTP. Its `productTelemetry` service owns submission only; product consumers own when an event occurs and which fields are approved. No Session data or identifiers are collected automatically. + +`ProductTelemetryRecord` requires an event name, string body, and Unix-millisecond timestamp. Attributes accept scalar strings, numbers, booleans, and one-level objects of those values (`ProductTelemetryScalar`). Optional severity uses OTel severity numbers and defaults to INFO. Observation time is assigned when the record is enqueued. + +Enqueue is synchronous and does not acknowledge delivery. The SDK owns batching and retry; local diagnostics report export failures. See the package README for configuration, shutdown, and loss limits. + +## Record types + +```ts type-equiv +/** Scalar values accepted by the collector's Arrow attributes map. */ +type ProductTelemetryScalar = string | number | boolean +``` + +```ts type-equiv +/** Explicitly selected analytics fields; object values may contain scalars only. */ +interface ProductTelemetryRecord { + /** Product/DA-owned event name. */ + eventName: string + /** Human-readable summary; never a prompt, response, credential, or file contents. */ + body: string + /** Event occurrence time in Unix milliseconds. Observation time is assigned on enqueue. */ + timestamp: number + /** OTel severity; omitted values use INFO. */ + severityNumber?: SeverityNumber + /** Business fields selected by the caller; no automatic device or account identity. */ + attributes?: Record> +} +``` + +Source: [`packages/host/product-telemetry-otel/src/index.ts`](../../packages/host/product-telemetry-otel/src/index.ts) + + + + + +## Cordis API + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.productTelemetry` — `ProductTelemetry` + +Host analytics sender. Mounting alone sends nothing; the owning fiber drains it on unload. + +```ts cordis-catalog +/** + * Enqueue one selected product event without waiting for network delivery. + * Queue admission and shutdown completion are not collector or warehouse acknowledgements. + * @param record - caller-owned event containing only approved analytics fields. + */ +emit(record: ProductTelemetryRecord): void +``` + +Source: [`packages/host/product-telemetry-otel/src/index.ts`](../../packages/host/product-telemetry-otel/src/index.ts) + diff --git a/docs/subsystems/product-telemetry.zh.md b/docs/subsystems/product-telemetry.zh.md new file mode 100644 index 0000000000..52ad119363 --- /dev/null +++ b/docs/subsystems/product-telemetry.zh.md @@ -0,0 +1,60 @@ +# 产品埋点 + +[English](product-telemetry.md) | 中文 + +[产品埋点插件](../../packages/host/product-telemetry-otel/README.zh.md) 通过 OTLP/HTTP 发送明确选定的分析事件。`productTelemetry` 服务仅负责提交;产品消费方决定事件发生时机与获准采集的字段。不自动采集 Session 数据或标识。 + +`ProductTelemetryRecord` 要求事件名称、字符串 body 和 Unix 毫秒时间戳。属性接受字符串、数字、布尔标量,以及这些值组成的单层对象(`ProductTelemetryScalar`)。可选的严重程度使用 OTel 严重程度数字,默认为 INFO。记录入队时填写观测时间。 + +入队同步完成,不代表送达确认。SDK 负责批量发送与重试;发送失败会产生本地诊断。配置、退出和丢失限制见包 README。 + +## 记录类型 + +```ts type-equiv +/** Scalar values accepted by the collector's Arrow attributes map. */ +type ProductTelemetryScalar = string | number | boolean +``` + +```ts type-equiv +/** Explicitly selected analytics fields; object values may contain scalars only. */ +interface ProductTelemetryRecord { + /** Product/DA-owned event name. */ + eventName: string + /** Human-readable summary; never a prompt, response, credential, or file contents. */ + body: string + /** Event occurrence time in Unix milliseconds. Observation time is assigned on enqueue. */ + timestamp: number + /** OTel severity; omitted values use INFO. */ + severityNumber?: SeverityNumber + /** Business fields selected by the caller; no automatic device or account identity. */ + attributes?: Record> +} +``` + +Source: [`packages/host/product-telemetry-otel/src/index.ts`](../../packages/host/product-telemetry-otel/src/index.ts) + + + + + +## Cordis API + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.productTelemetry` — `ProductTelemetry` + +Host analytics sender. Mounting alone sends nothing; the owning fiber drains it on unload. + +```ts cordis-catalog +/** + * Enqueue one selected product event without waiting for network delivery. + * Queue admission and shutdown completion are not collector or warehouse acknowledgements. + * @param record - caller-owned event containing only approved analytics fields. + */ +emit(record: ProductTelemetryRecord): void +``` + +Source: [`packages/host/product-telemetry-otel/src/index.ts`](../../packages/host/product-telemetry-otel/src/index.ts) + diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 5893d269ed..df718ac1e0 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -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/README.md -README.md: 5f759989eda6588e707a0c80c3035d0ba51ec950 -README.zh.md: 8a2784f5a3f99c9842b10d98d89b6d40936380b2 +README.md: 0ce8e9202763a2e974300b83239f02c256a2953e +README.zh.md: 8e0650d8f6b00112c4fdb4a4dbc11671a2b3f394 diff --git a/packages/README.md b/packages/README.md index 5f759989ed..0ce8e92027 100644 --- a/packages/README.md +++ b/packages/README.md @@ -77,7 +77,7 @@ Every package lives in exactly one group; new packages join existing groups, and | [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | | [`interaction/`](interaction/README.md) | Human-collaboration plane: approval/interaction seams, permission preset, commands, ask-user tool | | [`boot/`](boot/README.md) | Shared app-bin boot glue | -| [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server | +| [`host/`](host/README.md) | Web GUI host services, directory picking, application launch, plugin inventory, and product telemetry | | [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | | [`test-support/`](test-support/README.md) | Test infrastructure (testkits, replay, Loader smokes) | | [`runtime-diagnostics/`](runtime-diagnostics/README.md) | Runtime diagnostics: package-owned invariant checks and reports | diff --git a/packages/README.zh.md b/packages/README.zh.md index 8a2784f5a3..8e0650d8f6 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -77,7 +77,7 @@ harness 由 `packages/` 下的 npm 包组装而成,按能力系列分组:会 | [`acp/`](acp/README.zh.md) | 仅面向自动化的 ACP(Agent Client Protocol)服务器 | | [`interaction/`](interaction/README.zh.md) | 人机协作平面:批准/交互 seam、权限预设、命令、询问用户的工具 | | [`boot/`](boot/README.zh.md) | 共享的 app bin 启动粘合层 | -| [`host/`](host/README.zh.md) | web GUI 宿主半侧:API 网关 + HTTP 路由服务器 | +| [`host/`](host/README.zh.md) | Web GUI 宿主服务、目录选择、应用启动、插件清单和产品遥测 | | [`client/`](client/README.zh.md) | web GUI 浏览器半侧:shell、协议层、对象服务、slot、`ui-*` 插件 | | [`test-support/`](test-support/README.zh.md) | 测试基础设施(testkit、回放、Loader 冒烟测试) | | [`runtime-diagnostics/`](runtime-diagnostics/README.zh.md) | 运行时诊断:按包归属的运行时不变式检查与报告 | diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 84e8bd43ae..890059d48e 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -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/client/ui-deliverables/README.md -README.md: 7bf55c474250b8e389a1767092be0f82b4073222 -README.zh.md: d8b1fc368487d96091b400c6034d159989352192 +README.md: 7611e613073457c7843cc2a969e2f295fc204bbf +README.zh.md: 15162229dc4adc5c0143cd141481887a5c996be7 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 7bf55c4742..7611e61307 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -44,7 +44,9 @@ The card renders the summary the Host serves for the turn's latest `workspace/ch A row opens the turn's `changes-review` tab, addressed by the viewed Session and the announcing event's sequence and titled by the turn, on that row's file; another row of the same card reveals the same tab on its file. The header's file selector lists every recorded file with its counts and switches the comparison. A new tab starts in the side-by-side view without wrapping. Its controls switch between unified and side-by-side comparison, toggle line wrapping, open the whole current file in the Sidebar, and, with a Host desktop available, open it in the default application with the same pending and retryable-error states as the cards; the view and wrap choices are kept per tab. The tab reads the summary and each comparison once through the authenticated routes. A text comparison lists its hunks with the old and new line numbers of every line, and highlights each rendered hunk side independently with the code preview's filename grammar and Shiki token colours. Additions and deletions use aligned success or error markers and background tints. The tab also shows a note when the file was created or deleted in the turn, when both sides hold the same lines, when the Host's line comparison timed out and every line shows as replaced, or when the tab stopped drawing at 5,000 lines. A binary or oversized file, a comparison the Host no longer serves, and a failed read each show one line instead; a failed read offers Retry. The comparison is the turn's snapshot of the file, not its current content. -Without wrapping, the two columns synchronize vertical scrolling and horizontal offsets up to each side’s available range. Both horizontal scrollbars remain at the bottom of the visible comparison; scrolling vertically preserves a long line’s horizontal offset when the other side fits without horizontal scrolling. +The selected-file title uses the shared `ui-primitives` [`PathLabel`](../ui-primitives/README.md#component-catalog): complete and left-aligned when it fits, otherwise clipped at the left with a fade that preserves trailing characters and the extension. Directories use subdued text, the filename uses primary text, and hovering reveals the complete displayed path. Resizing the pane or selecting another file updates the fade; the dropdown arrow, line counts, and toolbar actions retain their space. + +Without wrapping, the two columns synchronize vertical scrolling and horizontal offsets up to each side’s available range. Line backgrounds, including empty alignment rows, cover each column’s full scrollable width. Both columns suppress elastic edge feedback and scroll chaining on both axes; the browser retains control of native momentum within each column’s scrollable range. Both horizontal scrollbars remain at the bottom of the visible comparison; scrolling vertically preserves a long line’s horizontal offset when the other side fits without horizontal scrolling. ### Inline-code links diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index d8b1fc3684..15162229dc 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -44,7 +44,9 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明 行在该行的文件上打开本轮的 `changes-review` tab,其地址由当前查看的 Session 和宣告事件的序号组成,以轮号作标题;同一张卡片的另一行会在其文件上显示同一个 tab。头部的文件选择器列出所有记录的文件及其行数,用于切换对比。新 tab 默认使用不换行的左右对比。其控件用于切换单栏或左右对比、切换自动换行、在 Sidebar 中打开当前完整文件,并在 Host 有桌面时用默认应用打开文件;原生打开的等待与可重试错误状态与卡片一致,视图和换行选择按 tab 保留。tab 通过经过认证的路由各读取一次摘要和每个对比。文本对比逐个列出 hunk,每一行带旧侧和新侧的行号,并按代码预览的文件名 grammar 与 Shiki token 色分别高亮每个已渲染 hunk 的两侧。新增与删除使用对齐的成功色或错误色标记和背景色。文件是本轮新建或删除、两侧内容相同、Host 的逐行对比超时而按整文件替换显示,或 tab 在 5000 行处停止绘制时,tab 还会给出一行说明。二进制或过大的文件、Host 已不再提供的对比,以及读取失败各显示一行提示;读取失败时提供重试。对比是本轮对该文件的快照,不是它当前的内容。 -关闭自动换行时,两列同步纵向滚动,横向偏移同步到各自可滚动范围内。两侧横向滚动条始终位于可见对比区域底部;如果只有一侧需要横向滚动,上下滚动会保留该侧的横向偏移。 +当前文件标题使用 `ui-primitives` 的共享组件 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog):空间足够时完整靠左显示,否则从左侧裁剪并渐隐,保留尾部字符和扩展名。目录使用弱化颜色,文件名使用主色,悬停显示完整路径。调整面板宽度或切换文件时更新渐隐;下拉箭头、行数和工具栏操作保留各自的空间。 + +关闭自动换行时,两列同步纵向滚动,横向偏移同步到各自可滚动范围内。各行背景(包括用于对齐的空行)覆盖所在列的完整可滚动宽度。两列在双轴上都禁用弹性边缘反馈和向外层传递滚动;各列可滚动范围内的原生惯性仍由浏览器控制。两侧横向滚动条始终位于可见对比区域底部;如果只有一侧需要横向滚动,上下滚动会保留该侧的横向偏移。 ### 行内代码链接 diff --git a/packages/client/ui-deliverables/src/client/FileDiff.module.css b/packages/client/ui-deliverables/src/client/FileDiff.module.css index cfefa1dea1..21d18dad44 100644 --- a/packages/client/ui-deliverables/src/client/FileDiff.module.css +++ b/packages/client/ui-deliverables/src/client/FileDiff.module.css @@ -5,7 +5,8 @@ .status { display: flex; gap: 12px; align-items: center; margin: 0; padding: 16px; font-size: 13px; color: var(--dsw-alias-label-secondary); } .body { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; overflow: auto; padding: 8px 0 16px; font: var(--dsw-font-markdown-code-block); } .columns { display: grid; flex: 1 1 auto; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-height: 0; } -.column { min-width: 0; overflow-x: scroll; } +/* One intrinsic-width track keeps every hunk and empty row painted across the scrollable content. */ +.column { display: grid; grid-template-columns: minmax(max-content, 100%); align-content: start; min-width: 0; overflow-x: scroll; overscroll-behavior: none; } .column + .column { border-left: 0.5px solid var(--dsw-alias-border-l3); } .sideLine { display: grid; grid-template-columns: 3.5em max-content; box-sizing: border-box; min-width: 100%; width: max-content; min-height: 22px; line-height: 22px; white-space: pre; } .note { margin: 0; padding: 4px 16px 8px; font: var(--dsw-font-xs-13); color: var(--dsw-alias-label-tertiary); } diff --git a/packages/client/ui-deliverables/src/client/FileDiff.tsx b/packages/client/ui-deliverables/src/client/FileDiff.tsx index 9822c3c4db..ca58fea399 100644 --- a/packages/client/ui-deliverables/src/client/FileDiff.tsx +++ b/packages/client/ui-deliverables/src/client/FileDiff.tsx @@ -167,6 +167,7 @@ function hunkHeader(hunk: WorkspaceDiffHunk): string { * lines and scroll together on both axes, so a long line on one side never * runs under the other and both sides show the same rows and columns of text. * Every line is one fixed-height row, which keeps the sides aligned. + * The columns suppress elastic overscroll while retaining native in-range scrolling. */ function SplitColumns({ hunks, highlights }: { hunks: readonly WorkspaceDiffHunk[]; highlights: readonly HunkHighlights[] }): ReactNode { const paired = useMemo(() => hunks.map(hunk => ({ header: hunkHeader(hunk), rows: splitRows(hunk) })), [hunks]) diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.module.css b/packages/client/ui-deliverables/src/client/ReviewTab.module.css index 28fb9b62f4..e0d7dd8df8 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.module.css +++ b/packages/client/ui-deliverables/src/client/ReviewTab.module.css @@ -5,7 +5,6 @@ .selectorButton { display: inline-flex; gap: 4px; align-items: center; box-sizing: border-box; max-width: 100%; height: 28px; padding: 0 6px 0 8px; border: 0; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-primary); cursor: pointer; font: inherit; font-size: 12px; line-height: 20px; } .selectorButton:hover, .selectorButton[aria-expanded='true'] { background: var(--dsw-alias-interactive-bg-hover); } .selectorButton svg { display: block; flex: none; } -.selectorText { min-width: 0; overflow: hidden; line-height: 20px; text-overflow: ellipsis; white-space: nowrap; } .item { display: flex; gap: 12px; align-items: center; justify-content: space-between; min-width: 0; } .itemPath { min-width: 0; overflow: hidden; line-height: 20px; text-overflow: ellipsis; white-space: nowrap; } .itemCounts, .counts { display: inline-flex; flex: none; gap: 6px; align-items: center; font-family: var(--ds-font-family-code); font-size: 12px; line-height: 20px; color: var(--dsw-alias-label-tertiary); } diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.tsx b/packages/client/ui-deliverables/src/client/ReviewTab.tsx index 9574dd7bdf..7840fc57eb 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.tsx +++ b/packages/client/ui-deliverables/src/client/ReviewTab.tsx @@ -7,7 +7,7 @@ import { useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import { IconChevronDownOutlineRegular, IconCompareSplitOutlineRegular, IconInspectOutlineRegular, - IconNowrapFillRegular, IconWrapFillRegular, Menu, Tooltip, + IconNowrapFillRegular, IconWrapFillRegular, Menu, PathLabel, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' @@ -117,7 +117,7 @@ export function ReviewTab({ anchor={} items={files.map((entry, at) => ({ id: String(at), label: diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 226e69ca63..199a8ac83a 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -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/client/ui-primitives/README.md -README.md: c3bf0ca47b2dadee8b27aecc521ca95be7fad524 -README.zh.md: 83962a3ff7257bf62acec0a0679b467d65a400c9 +README.md: aa4ce186ab0d1ff5619f95497fa6abf9f94078ca +README.zh.md: 4d907d5db649f301e5e0b7c47f2457b7aecd89fe diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index c3bf0ca47b..aa4ce186ab 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -45,6 +45,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. | | `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. | | `Tag` | Read-only capsule badge; `tone` selects one of eight palettes. | +| `PathLabel` | Single-line file path with subdued directories, a primary filename, and the full path on hover. Fitting paths align left; clipped paths preserve their suffix with a left-edge fade that updates on path and size changes. | | `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader whose animations pin to document time zero so every visible loader rotates in phase. `aria-hidden`, so the render site owns the name. `appearance="step"` shows a filled check for completion and a hollow pending circle. | | `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. | | `DisclosureRow` | 24px compact disclosure that lays title and content side by side. Memoized with shallow prop comparison; keep callbacks and React-node props stable when their content is unchanged. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 83962a3ff7..4d907d5db6 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -45,6 +45,7 @@ kind: "package-library" | `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 | | `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。 | | `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 | +| `PathLabel` | 单行文件路径:目录使用弱化颜色,文件名使用主色,悬停可查看完整路径。空间足够时靠左显示;溢出时保留尾部并在左侧渐隐,路径或尺寸变化时更新。 | | `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading,其动画固定到文档时间零点,所以所有可见 loading 同相旋转。它是 `aria-hidden` 的,名称由渲染点提供。 `appearance="step"` 以实心勾表示完成、空心圆表示等待。 | | `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 | | `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。使用浅层 prop 比较进行 memo;内容未变时,保持回调与 React 节点 prop 的引用稳定。 | diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index bb7b2de32e..a80e556607 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -39,6 +39,7 @@ "micromark-util-symbol": "^2.0.1", "micromark-util-types": "^2.0.2", "shiki": "^4.3.1", + "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@shikijs/langs": "^4.3.1", "@types/mdast": "^4.0.4", "micromark-factory-space": "^2.0.1", diff --git a/packages/client/ui-primitives/src/PathLabel.module.css b/packages/client/ui-primitives/src/PathLabel.module.css new file mode 100644 index 0000000000..add09a8550 --- /dev/null +++ b/packages/client/ui-primitives/src/PathLabel.module.css @@ -0,0 +1,28 @@ +/* Auto margin keeps fitting text at the left. On overflow it becomes zero, + so flex-end preserves the filename suffix at the right edge. */ +.path { + display: flex; + flex: 1 1 auto; + justify-content: flex-end; + min-width: 0; + overflow: hidden; + font-size: 12px; + white-space: nowrap; +} + +.path[data-path-clipped] { + mask-image: linear-gradient(to right, transparent, black 28px); +} + +.text { + flex: none; + margin-right: auto; +} + +.directory { + color: var(--dsw-alias-label-tertiary); +} + +.name { + color: var(--dsw-alias-label-primary); +} diff --git a/packages/client/ui-primitives/src/PathLabel.tsx b/packages/client/ui-primitives/src/PathLabel.tsx new file mode 100644 index 0000000000..ed500f738e --- /dev/null +++ b/packages/client/ui-primitives/src/PathLabel.tsx @@ -0,0 +1,41 @@ +/** A single-line file path whose trailing characters remain visible in narrow toolbars. */ +import { useLayoutEffect, useRef, type HTMLAttributes } from 'react' +import clsx from 'clsx' +import { pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path' +import css from './PathLabel.module.css' + +/** + * Render subdued directories and a primary filename, with the complete path on hover. + * Fitting text is left-aligned; overflow clips and fades at the left edge. + * The fade updates on path changes and, when ResizeObserver is available, size changes. + * @param props - File path and attributes for its outer span; callers own toolbar spacing. + * @returns the path label. + */ +export function PathLabel({ path, className, ...attributes }: { + path: string +} & Omit, 'children' | 'title'>) { + const boxRef = useRef(null) + const textRef = useRef(null) + const { directory, name } = pathPartsOf(path) + useLayoutEffect(() => { + // Both spans mount unconditionally before this layout effect runs. + const outer = boxRef.current as HTMLSpanElement + const inner = textRef.current as HTMLSpanElement + const apply = (): void => { + outer.toggleAttribute('data-path-clipped', inner.offsetWidth > outer.clientWidth) + } + apply() + const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(apply) + observer?.observe(outer) + observer?.observe(inner) + return () => { observer?.disconnect() } + }, [path]) + return ( + + + {directory !== '' && {directory}} + {name} + + + ) +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 4ac5b81f25..a15f4df35b 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -15,6 +15,7 @@ export { SegmentedTabs } from './SegmentedTabs.tsx' export type { SegmentedTab } from './SegmentedTabs.tsx' export { Tag } from './Tag.tsx' export type { TagTone } from './Tag.tsx' +export { PathLabel } from './PathLabel.tsx' export { Switch } from './Switch.tsx' export { SegmentedControl } from './SegmentedControl.tsx' export type { SegmentedControlOption } from './SegmentedControl.tsx' diff --git a/packages/client/ui-primitives/tests/path-label.client.spec.tsx b/packages/client/ui-primitives/tests/path-label.client.spec.tsx new file mode 100644 index 0000000000..3336338b68 --- /dev/null +++ b/packages/client/ui-primitives/tests/path-label.client.spec.tsx @@ -0,0 +1,94 @@ +// @vitest-environment jsdom +/** Path labels retain their full hover text and update clipping with layout and file changes. */ +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PathLabel } from '../src/PathLabel.tsx' + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('PathLabel', () => { + it.each([ + ['src/filename.ts', 'src/', 'filename.ts'], + ['C:\\src\\filename.ts', 'C:\\src\\', 'filename.ts'], + ['filename.ts', '', 'filename.ts'], + ])('shows %s with a separate filename and full hover text', (path, directory, name) => { + const view = render() + const label = view.getByTestId('path') + expect(label.textContent).toBe(path) + expect(label.title).toBe(path) + expect(label.classList.contains('placement')).toBe(true) + expect(label.firstElementChild?.lastElementChild?.textContent).toBe(name) + expect(label.firstElementChild?.children.length).toBe(directory === '' ? 1 : 2) + }) + + it('marks the path clipped while its text is wider than its box, re-reading on resize', () => { + class FakeResizeObserver implements ResizeObserver { + static latest: FakeResizeObserver | undefined + readonly observe = vi.fn() + readonly unobserve = vi.fn() + readonly disconnect = vi.fn() + constructor(private readonly callback: ResizeObserverCallback) { + FakeResizeObserver.latest = this + } + + fire(): void { + this.callback([], this) + } + } + vi.stubGlobal('ResizeObserver', FakeResizeObserver) + let boxWidth = 300 + const offsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth') + const clientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth') + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 200 }) + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => boxWidth }) + try { + const view = render() + const path = view.container.querySelector('[data-path-label]') + const text = path?.firstElementChild + expect(path?.hasAttribute('data-path-clipped')).toBe(false) + const observer = FakeResizeObserver.latest + if (observer === undefined) throw new Error('expected the path to observe its size') + expect(observer.observe).toHaveBeenCalledWith(path) + expect(observer.observe).toHaveBeenCalledWith(text) + + boxWidth = 120 + act(() => { observer.fire() }) + expect(path?.hasAttribute('data-path-clipped')).toBe(true) + + boxWidth = 300 + act(() => { observer.fire() }) + expect(path?.hasAttribute('data-path-clipped')).toBe(false) + view.unmount() + expect(observer.disconnect).toHaveBeenCalledTimes(1) + } finally { + vi.unstubAllGlobals() + for (const [name, descriptor] of [['offsetWidth', offsetWidth], ['clientWidth', clientWidth]] as const) { + if (descriptor === undefined) Reflect.deleteProperty(HTMLElement.prototype, name) + else Object.defineProperty(HTMLElement.prototype, name, descriptor) + } + } + }) + + it('rechecks clipping and hover text immediately when the selected path changes without ResizeObserver', () => { + vi.stubGlobal('ResizeObserver', undefined) + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) { + return (this.textContent?.length ?? 0) * 10 + }) + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(100) + const view = render() + const label = view.container.querySelector('[data-path-label]')! + expect(label.hasAttribute('data-path-clipped')).toBe(false) + view.rerender() + expect(label.hasAttribute('data-path-clipped')).toBe(true) + expect(label.title).toBe('src/long-filename-before.ts') + view.rerender() + expect(label.textContent).toBe('src/long-filename-after.ts') + view.rerender() + expect(label.hasAttribute('data-path-clipped')).toBe(false) + expect(label.title).toBe('b.ts') + }) +}) diff --git a/packages/client/ui-primitives/tsconfig.json b/packages/client/ui-primitives/tsconfig.json index 201a8a711e..da20029ef8 100644 --- a/packages/client/ui-primitives/tsconfig.json +++ b/packages/client/ui-primitives/tsconfig.json @@ -13,6 +13,9 @@ "references": [ { "path": "../store" + }, + { + "path": "../../util/workspace-path" } ] } diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 1fae2bc487..fe86787ea2 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -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/client/ui-sidebar-documentpreview/README.md -README.md: 67136b8e5ff81dee26905be7734c981f18fc3b0a -README.zh.md: 9686863b9d265e7902f47948e5d73476bd023726 +README.md: b82a1ca3179c6a7bb6bf9a67369a4e877d229ed3 +README.zh.md: 4313b815c01c1d2d0d9b9669a2fb6274b0ca61f8 diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 67136b8e5f..b82a1ca317 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -29,7 +29,7 @@ Preview files in the right Sidebar and choose among registered renderers. Markdo ## What it registers - **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, pattern `dsh-resource://file/**`, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title, and the keyed `sidebar.right.pane.tab.title` seat places its extension-specific `FileTypeIcon` before that title. -- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; directories use tertiary label colour, the name uses primary label colour, and a clipped path retains and fades toward its final segment while its tooltip exposes the full value. A dropdown appears when multiple supported renderers are available. Plain text is offered only for text-compatible sources; a single renderer shows no viewer control. A known binary container suffix without a registered renderer shows the file-type icon and an unsupported-preview message under the path header, without issuing a read. Two Session-scoped list child slots hand the previewed file to other plugins once its metadata reports a Host path, both with `absolutePath` as the owner prop: `sidebar.right.tab.document.actions` renders after the header's own controls in every state that shows the header, and `sidebar.right.tab.document.unpreviewable` renders in the unsupported empty state and in the empty state of a readable file the preview cannot render (`not-text`, `too-large`), where Retry would otherwise stand; a carrier or unclassified read failure keeps Retry, and a missing or non-regular path offers only its explanation. The shipped occupant of both is [`ui-open-in-app`](../ui-open-in-app/README.md). A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. Automatic refresh defaults to enabled; its separate toggle is hidden, with state, labels, styles, and toggle logic retained. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the Files tab's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge. +- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; the shared [`PathLabel`](../ui-primitives/README.md#component-catalog) subdues directories, preserves a clipped path’s trailing characters with a left-edge fade, and exposes the full value on hover. A dropdown appears when multiple supported renderers are available. Plain text is offered only for text-compatible sources; a single renderer shows no viewer control. A known binary container suffix without a registered renderer shows the file-type icon and an unsupported-preview message under the path header, without issuing a read. Two Session-scoped list child slots hand the previewed file to other plugins once its metadata reports a Host path, both with `absolutePath` as the owner prop: `sidebar.right.tab.document.actions` renders after the header's own controls in every state that shows the header, and `sidebar.right.tab.document.unpreviewable` renders in the unsupported empty state and in the empty state of a readable file the preview cannot render (`not-text`, `too-large`), where Retry would otherwise stand; a carrier or unclassified read failure keeps Retry, and a missing or non-regular path offers only its explanation. The shipped occupant of both is [`ui-open-in-app`](../ui-open-in-app/README.md). A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. Automatic refresh defaults to enabled; its separate toggle is hidden, with state, labels, styles, and toggle logic retained. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the Files tab's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge. - **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, pending Resource changes, automatic-refresh state, loading/failure state, renderer choice, scroll offset, wrap, and the answered navigation revision. The ordinary inject face calls Remote readers and writes through declared store actions. Reloads and loading-mode changes retire older requests; the tab's abort signal forgets its state. Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. `binaryExtensions` lists suffixes in `extensions` that cannot be read as text and omit the plain-text option. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, `content`, `wrap`, `scrollportRef`, `addResource`, `setResources`, and the standard `useTabInfo`/`useResource` hooks. Dependency membership belongs to a tab-owned `ResourceGroup`; replacing dependencies always retains the root Resource. An inner scrolling element attaches `scrollportRef`; unmounting restores the shared body as scroll owner. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available. HTML, SVG, and unmatched extensions retain the plain-text fallback independently of loading mode. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index 9686863b9d..4313b815c0 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ## 注册了什么 - **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统中的身份,也是其正文注册所用的键),kind `text`,pattern `dsh-resource://file/**`,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题,keyed slot `sidebar.right.pane.tab.title` 会在标题前放置按扩展名选择的 `FileTypeIcon`。 -- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;目录使用三级标签色,文件名使用一级标签色,路径过长时保留末段并向开头淡出,提示中仍提供完整值。有多个受支持的渲染器时才显示下拉菜单。仅文本兼容的源文件提供纯文本选项;只有一个渲染器时不显示查看器控件。已知的二进制容器后缀没有注册渲染器时,在路径头部下方显示文件类型图标和不支持预览的说明,并且不会发起读取。两个 Session 作用域的 list 子 slot 在文件元数据报出 Host 路径后把当前文件交给其他插件,owner prop 为 `absolutePath`:`sidebar.right.tab.document.actions` 在每个显示头部的状态里渲染在头部自有控件之后;`sidebar.right.tab.document.unpreviewable` 渲染在不支持预览的空态里,以及可读但本预览无法渲染的文件(`not-text`、`too-large`)的空态里,占据原本放重试的位置;载体或未分类的读取失败仍保留重试,不存在或非普通文件的路径只给说明。两者已发布的占位者都是 [`ui-open-in-app`](../ui-open-in-app/README.zh.md)。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与 Files tab 右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。 +- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;共享的 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog) 弱化目录颜色,以左侧渐隐保留被裁剪路径的尾部字符,并在悬停时展示完整值。有多个受支持的渲染器时才显示下拉菜单。仅文本兼容的源文件提供纯文本选项;只有一个渲染器时不显示查看器控件。已知的二进制容器后缀没有注册渲染器时,在路径头部下方显示文件类型图标和不支持预览的说明,并且不会发起读取。两个 Session 作用域的 list 子 slot 在文件元数据报出 Host 路径后把当前文件交给其他插件,owner prop 为 `absolutePath`:`sidebar.right.tab.document.actions` 在每个显示头部的状态里渲染在头部自有控件之后;`sidebar.right.tab.document.unpreviewable` 渲染在不支持预览的空态里,以及可读但本预览无法渲染的文件(`not-text`、`too-large`)的空态里,占据原本放重试的位置;载体或未分类的读取失败仍保留重试,不存在或非普通文件的路径只给说明。两者已发布的占位者都是 [`ui-open-in-app`](../ui-open-in-app/README.zh.md)。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与 Files tab 右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。 - **共享加载与视图状态**,会话作用域、按 tab id 分桶。store 持有累计页或完整字节、读取与观察版本、待处理 Resource 变化、自动刷新状态、加载/失败状态、渲染器选择、滚动位置、换行和已响应的导航 revision。普通 inject face 调用 Remote 读取,并经声明的 store action 写入。重新载入和加载模式变化会淘汰旧请求;tab 的中止信号清理其状态。 文档实现在 `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。`binaryExtensions` 列出 `extensions` 中不可按文本阅读的后缀,这些后缀不提供纯文本选项。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、`content`、`wrap`、`scrollportRef`、`addResource`、`setResources` 和标准 `useTabInfo`/`useResource` 钩子。依赖成员由 tab 持有的 `ResourceGroup` 管理;替换依赖始终保留根 Resource。内部滚动元素挂载 `scrollportRef`;卸载时恢复共享正文的滚动职责。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变。HTML、SVG 和未匹配的扩展名保留纯文本回退,与加载方式无关。 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css index 490ec8e67c..afc740dc5e 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css @@ -28,38 +28,8 @@ border-bottom: 0.5px solid var(--dsw-alias-border-l3); } -/* The path is never ellipsized. Its text sits at the left while it fits: the - auto margin takes the free space. Once it is wider than the box, the auto - margin is zero and `flex-end` holds the name at the right edge, so the - directories run off the left and the box clips them there, faded by the - mask while `data-textpreview-path-clipped` is set. The margin plus the row's - gap put the text 16px clear of the first control. */ .path { - display: flex; - flex: 1 1 auto; - justify-content: flex-end; - min-width: 0; margin-right: 12px; - overflow: hidden; - font-size: 12px; - white-space: nowrap; -} - -.path[data-textpreview-path-clipped] { - mask-image: linear-gradient(to right, transparent, black 28px); -} - -.pathText { - flex: none; - margin-right: auto; -} - -.pathDirectory { - color: var(--dsw-alias-label-tertiary); -} - -.pathName { - color: var(--dsw-alias-label-primary); } /* Announced, not applied: the reader keeps the text they are looking at. */ diff --git a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx index 34efd79edb..b9e03e1748 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx @@ -9,14 +9,14 @@ * with the same reload. The type's controls, viewer choice, wrap and reload, sit at the end of * the path row; the Sidebar's strip carries none of them. */ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import type { ReactNode, RefObject } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { ReactNode } from 'react' import clsx from 'clsx' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import { FileTypeIcon, IconNowrapFillRegular, IconPauseOutlineRegular, IconPlayOutlineRegular, - IconRefreshOutlineRegular, IconWrapFillRegular, Menu, Tooltip, classifyFileType, + IconRefreshOutlineRegular, IconWrapFillRegular, Menu, PathLabel, Tooltip, classifyFileType, } from '@deepseek-ai/dsh-client-ui-primitives' import { pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path' import type { TextInjected } from './face.ts' @@ -35,46 +35,6 @@ import css from './TextPreview.module.css' export { linesOf, loadedPages, lastLineLoaded, scrollToLine } from './text/lines.ts' export type { LoadedPage } from './text/lines.ts' -/** Keep the path fade in sync with whether its full text fits the header row. */ -function usePathClipped( - box: RefObject, - text: RefObject, - path: string, - shown: boolean, -): void { - useLayoutEffect(() => { - const outer = box.current - const inner = text.current - if (outer === null || inner === null) return undefined - const apply = (): void => { - if (inner.offsetWidth > outer.clientWidth) outer.dataset.textpreviewPathClipped = '' - else delete outer.dataset.textpreviewPathClipped - } - apply() - const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(apply) - observer?.observe(outer) - observer?.observe(inner) - return () => { observer?.disconnect() } - }, [box, text, path, shown]) -} - -/** The header's path: directories greyed, the final segment in full ink, faded when clipped. */ -function HeaderPath({ pathRef, pathTextRef, path }: { - pathRef: RefObject - pathTextRef: RefObject - path: string -}): ReactNode { - const { directory, name } = pathPartsOf(path) - return ( -
- - {directory !== '' && {directory}} - {name} - -
- ) -} - /** Private registration inputs; the framework binds the registry source to useDocumentPreviews. */ export interface TextPreviewInjected extends TextInjected { readonly hooks: { readonly documentPreviews: ObservableSnapshot } @@ -129,14 +89,11 @@ export function TextPreview({ const bodyRef = useRef(null) const scrollportRef = useRef(null) const storedScrollTopRef = useRef(0) - const pathRef = useRef(null) - const pathTextRef = useRef(null) const [menuOpen, setMenuOpen] = useState(false) const absolutePath = meta.value?.absolutePath ?? current?.complete?.absolutePath const displayPath = absolutePath ?? file.path // Contributions that hand the file to the Host wait for its Host path. const fileOwner = absolutePath === undefined ? undefined : { absolutePath } - usePathClipped(pathRef, pathTextRef, displayPath, state !== undefined) // Every tab of this type is a `file` resource address, so its params are the // `file` type's; the union is narrowed on the one field read, not validated. const line = navigation.params !== undefined && 'line' in navigation.params ? navigation.params.line : undefined @@ -243,7 +200,7 @@ export function TextPreview({ return (
- + {fileOwner !== undefined && renderSlot('sidebar.right.tab.document.actions', fileOwner)}
@@ -305,7 +262,7 @@ export function TextPreview({

)}
- + {candidates.length > 1 && ( { expect(view.container.querySelector('[data-textpreview-path]')?.getAttribute('title')).toBe(ABSOLUTE_PATH) }) - it('marks the path clipped while its text is wider than its box, re-reading on resize', async () => { - class FakeResizeObserver implements ResizeObserver { - static latest: FakeResizeObserver | undefined - readonly observe = vi.fn() - readonly unobserve = vi.fn() - readonly disconnect = vi.fn() - constructor(private readonly callback: ResizeObserverCallback) { - FakeResizeObserver.latest = this - } - - fire(): void { - this.callback([], this) - } - } - vi.stubGlobal('ResizeObserver', FakeResizeObserver) - let boxWidth = 300 - const offsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth') - const clientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth') - Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 200 }) - Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => boxWidth }) - try { - const h = harness({ 1: page(1, ['one'], true) }) - const view = render() - await settle() - const path = view.container.querySelector('[data-textpreview-path]') - const text = path?.firstElementChild - expect(path?.hasAttribute('data-textpreview-path-clipped')).toBe(false) - const observer = FakeResizeObserver.latest - if (observer === undefined) throw new Error('expected the path to observe its size') - expect(observer.observe).toHaveBeenCalledWith(path) - expect(observer.observe).toHaveBeenCalledWith(text) - - boxWidth = 120 - act(() => { observer.fire() }) - expect(path?.hasAttribute('data-textpreview-path-clipped')).toBe(true) - - boxWidth = 300 - act(() => { observer.fire() }) - expect(path?.hasAttribute('data-textpreview-path-clipped')).toBe(false) - view.unmount() - expect(observer.disconnect).toHaveBeenCalledTimes(1) - } finally { - vi.unstubAllGlobals() - for (const [name, descriptor] of [['offsetWidth', offsetWidth], ['clientWidth', clientWidth]] as const) { - if (descriptor === undefined) Reflect.deleteProperty(HTMLElement.prototype, name) - else Object.defineProperty(HTMLElement.prototype, name, descriptor) - } - } - }) - it('reads the first page on first mount and draws its lines, offering the next', async () => { const h = harness({ 1: page(1, ['one', 'two', 'three'], false) }) const view = render() diff --git a/packages/client/ui-sidebar-files/README.i18n.yaml b/packages/client/ui-sidebar-files/README.i18n.yaml index 9edfb4b6cc..2092c7ee06 100644 --- a/packages/client/ui-sidebar-files/README.i18n.yaml +++ b/packages/client/ui-sidebar-files/README.i18n.yaml @@ -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/client/ui-sidebar-files/README.md -README.md: 491072c1daa95dc33cb268e7c825a95d738fae7c -README.zh.md: eceffb1c3ac0f7ba0437b23ee9e1ed6f905f5369 +README.md: 0340991f16a61b80cd616982c6a6bea6ff60beac +README.zh.md: 016c74d801a3582785cb600f493eaeb92f7567b9 diff --git a/packages/client/ui-sidebar-files/README.md b/packages/client/ui-sidebar-files/README.md index 491072c1da..0340991f16 100644 --- a/packages/client/ui-sidebar-files/README.md +++ b/packages/client/ui-sidebar-files/README.md @@ -25,7 +25,7 @@ Browse a Session's workspace tree and open files in Sidebar previews. The root a ## What it registers - **The type** — `ctx.sidebarRightTabs.register(...)` with kind `files`, id `@deepseek-ai/dsh-client-ui-sidebar-files`, band `builtin`, no patterns, and one guide entry (order 10, its title and description from the `sidebarFiles` namespace, its glyph the shared folder icon) that opens the type. -- **The body** — the keyed `sidebar.right.pane.tab` seat under that id: a header row under the strip, then the tree. The header row is the document preview's (`ui-sidebar-documentpreview`): the root path, its directories greyed and its last segment in full ink, never ellipsized (a path wider than the row keeps its end and fades its start), with the visible reload control at its right. The row is copied rather than shared because a plugin bundle shares runtime code only through the platform modules; once the artifact and slot surfaces settle, one copy in `ui-primitives` could serve every pane header. +- **The body** — the keyed `sidebar.right.pane.tab` seat under that id: a header row under the strip, then the tree. The shared [`PathLabel`](../ui-primitives/README.md#component-catalog) displays the root path with subdued directories and a primary final segment. A clipped path retains its trailing characters with a left-edge fade; hovering reveals the full path. The reload control stays at its right. - **The chip title** — the keyed `sidebar.right.pane.tab.title` seat under that id: a shared `FileTypeIcon` folder glyph at 16px before the type's label. The tree's own rows never draw this sheet. Source files under `src/client/`: `definition.tsx` (the type), `store.ts` (what it keeps), `face.ts` (Remote reads and watches), `directory-node.ts` (open directories and their lifetimes), `FilesBody.tsx` (what it draws, with its ordering and failure-line helpers), `FilesTitle.tsx` (the chip title), `locales.ts` (what it says), and `index.ts` (the wiring). @@ -33,7 +33,7 @@ Source files under `src/client/`: `definition.tsx` (the type), `store.ts` (what ## The tree -The root is the session's working directory, read from `useSessions().byId[sessionId].cwd`, and split for the header row by `pathPartsOf` from `@deepseek-ai/dsh-util-workspace-path`. Filesystem roots such as `/` and Windows drive roots are valid tree roots. Every level is keyed by absolute path; a child's path is its parent's joined with the entry name by `/`. A level is listed when it is first expanded, through `remote.workspaceFiles.list(sessionId, absolutePath)` on the `@deepseek-ai/dsh-api-workspace-files` namespace; the adapter keeps the listing's entries and truncation flag and drops its workspace-relative path. Rows are ordered directories first, then by natural, case-insensitive name; dotfiles are shown like any other entry. +The root is the session's working directory, read from `useSessions().byId[sessionId].cwd`. Filesystem roots such as `/` and Windows drive roots are valid tree roots. Every level is keyed by absolute path; a child's path is its parent's joined with the entry name by `/`. A level is listed when it is first expanded, through `remote.workspaceFiles.list(sessionId, absolutePath)` on the `@deepseek-ai/dsh-api-workspace-files` namespace; the adapter keeps the listing's entries and truncation flag and drops its workspace-relative path. Rows are ordered directories first, then by natural, case-insensitive name; dotfiles are shown like any other entry. | Entry type | Row | |---|---| diff --git a/packages/client/ui-sidebar-files/README.zh.md b/packages/client/ui-sidebar-files/README.zh.md index eceffb1c3a..016c74d801 100644 --- a/packages/client/ui-sidebar-files/README.zh.md +++ b/packages/client/ui-sidebar-files/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 注册了什么 - **类型**:`ctx.sidebarRightTabs.register(...)`,kind 为 `files`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-files`,档位 `builtin`,没有 patterns,另有一个打开该类型的引导页入口(order 10,标题与描述取自 `sidebarFiles` 命名空间,图标是共享的文件夹图标)。 -- **正文**:以该 id 为键的 `sidebar.right.pane.tab` slot:strip 下的一行标题行,然后是树。标题行与文档预览(`ui-sidebar-documentpreview`)的相同:根路径,目录部分灰色、最后一段正色,从不省略号截断(比行宽的路径保留末尾、淡出开头),右端显示重新读取控件。这一行是复制而非共享,因为插件 bundle 只经平台模块共享运行时代码;待 artifact 与各 slot 的形态定下来后,可以在 `ui-primitives` 放一份供每个 pane 标题行使用。 +- **正文**:以该 id 为键的 `sidebar.right.pane.tab` slot:strip 下的一行标题行,然后是树。共享的 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog) 显示根路径,目录使用弱化颜色,最后一段使用主色。路径过长时保留尾部字符并在左侧渐隐;悬停显示完整路径。重新读取控件位于右端。 - **标签页标题**:以该 id 为键的 `sidebar.right.pane.tab.title` slot:类型标签前的一枚 16px 共享 `FileTypeIcon` 文件夹图标。树本身的行不画这枚图标。 `src/client/` 下的源文件:`definition.tsx`(类型是什么)、`store.ts`(它保存什么)、`face.ts`(Remote 读取与监听)、`directory-node.ts`(打开的目录及其生命周期)、`FilesBody.tsx`(它画什么,含排序与失败行两个辅助函数)、`FilesTitle.tsx`(标签页标题)、`locales.ts`(它说什么)、`index.ts`(接线)。 @@ -33,7 +33,7 @@ kind: "package-reference" ## 树 -根是会话的工作目录,读自 `useSessions().byId[sessionId].cwd`,标题行里的拆分由 `@deepseek-ai/dsh-util-workspace-path` 的 `pathPartsOf` 给出。`/` 和 Windows 盘根等文件系统根路径均可作为树的根。每一层以绝对路径为键;子路径是父路径以 `/` 拼上条目名。一层在首次展开时经 `@deepseek-ai/dsh-api-workspace-files` 命名空间的 `remote.workspaceFiles.list(sessionId, absolutePath)` 列出;适配器保留列表的条目与截断标志,丢弃其工作区相对路径。行序为目录优先,其后按自然序、不分大小写的名称排列;dotfiles 与其他条目一样显示。 +根是会话的工作目录,读自 `useSessions().byId[sessionId].cwd`。`/` 和 Windows 盘根等文件系统根路径均可作为树的根。每一层以绝对路径为键;子路径是父路径以 `/` 拼上条目名。一层在首次展开时经 `@deepseek-ai/dsh-api-workspace-files` 命名空间的 `remote.workspaceFiles.list(sessionId, absolutePath)` 列出;适配器保留列表的条目与截断标志,丢弃其工作区相对路径。行序为目录优先,其后按自然序、不分大小写的名称排列;dotfiles 与其他条目一样显示。 | 条目类型 | 行 | |---|---| diff --git a/packages/client/ui-sidebar-files/src/client/FilesBody.module.css b/packages/client/ui-sidebar-files/src/client/FilesBody.module.css index a069eec6a8..aaf4fdd9d6 100644 --- a/packages/client/ui-sidebar-files/src/client/FilesBody.module.css +++ b/packages/client/ui-sidebar-files/src/client/FilesBody.module.css @@ -12,13 +12,7 @@ line-height: 1.5; } -/* The header row, its path, and its control are the text preview's - (ui-sidebar-documentpreview TextPreview `.header`, `.path`, `.tool`), copied so - the two pane types read as one; keep the two sheets in step. TODO: once the - artifact and slot surfaces settle, one copy in ui-primitives could serve - every pane header. - - One 38px row flush under the pane's strip: the root's path, then reload at +/* One 38px row flush under the pane's strip: the root's path, then reload at its end. With the strip's 38px above, its rule lies where the conversation header's does (ui-conversation ConversationRoot `.header`, 76px with the same rule). */ @@ -33,38 +27,9 @@ border-bottom: 0.5px solid var(--dsw-alias-border-l3); } -/* The path is never ellipsized. Its text sits at the left while it fits: the - auto margin takes the free space. Once it is wider than the box, the auto - margin is zero and `flex-end` holds the name at the right edge, so the - directories run off the left and the box clips them there, faded by the - mask while `data-files-path-clipped` is set. The margin plus the row's gap - put the text 16px clear of the first control. */ +/* The margin plus the header's gap keeps the path 16px clear of reload. */ .path { - display: flex; - flex: 1 1 auto; - justify-content: flex-end; - min-width: 0; margin-right: 12px; - overflow: hidden; - font-size: 12px; - white-space: nowrap; -} - -.path[data-files-path-clipped] { - mask-image: linear-gradient(to right, transparent, black 28px); -} - -.pathText { - flex: none; - margin-right: auto; -} - -.pathDirectory { - color: var(--dsw-alias-label-tertiary); -} - -.pathName { - color: var(--dsw-alias-label-primary); } .body { diff --git a/packages/client/ui-sidebar-files/src/client/FilesBody.tsx b/packages/client/ui-sidebar-files/src/client/FilesBody.tsx index 3b707f94a4..68f6e1c6a7 100644 --- a/packages/client/ui-sidebar-files/src/client/FilesBody.tsx +++ b/packages/client/ui-sidebar-files/src/client/FilesBody.tsx @@ -5,20 +5,19 @@ * for goes through its injected face. The component itself only decides what to * draw for each absolute path and what a click means: a directory toggles, a * file opens through the owner's `tabActions` for a `file:` viewer to claim, and - * anything else is shown but refuses to open. The header row is the text - * preview's: the root's path, directories greyed and the last segment in full - * ink, then reload, which refreshes the expanded directories in place. + * anything else is shown but refuses to open. The header uses the shared + * PathLabel for the root, followed by reload for the expanded directories. */ import { useEffect, useLayoutEffect, useRef } from 'react' -import type { ReactNode, RefObject } from 'react' +import type { ReactNode } from 'react' import clsx from 'clsx' import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' import type { PropsLocale, PropsRuntime, PropsStore, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import { FileTypeIcon, IconFolderCloseRegular, IconFolderOpenRegular, IconRefreshOutlineRegular, classifyFileType, - IconPauseOutlineRegular, IconPlayOutlineRegular, + IconPauseOutlineRegular, IconPlayOutlineRegular, PathLabel, } from '@deepseek-ai/dsh-client-ui-primitives' -import { fileAddressFor, pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path' +import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path' import type { WorkspaceDirectoryEntry } from '@deepseek-ai/dsh-api-workspace-files/types' import { childPath } from './face.ts' import type { FilesInjected } from './face.ts' @@ -67,39 +66,6 @@ export function failureLine(t: TranslateNS<'sidebarFiles'>, failure: RemoteFailu } } -/* jscpd:ignore-start -- the header row is the document preview's (ui-sidebar-documentpreview - TextPreview `usePathClipped`), copied because a plugin bundle shares runtime code - only through the platform modules. TODO: once the artifact and slot surfaces - settle, one copy in ui-primitives could serve every pane header. */ -/** - * Keep the path row's `data-files-path-clipped` current: set while the path's - * text is wider than its box, so the stylesheet fades the clipped start. Read - * after each commit that can change the path or mount the header, and whenever - * either box resizes; written to the DOM directly because it changes only how - * the stylesheet fades what is already rendered. - */ -function usePathClipped( - box: RefObject, - text: RefObject, - path: string | undefined, -): void { - useLayoutEffect(() => { - const outer = box.current - const inner = text.current - if (outer === null || inner === null) return undefined - const apply = (): void => { - if (inner.offsetWidth > outer.clientWidth) outer.dataset.filesPathClipped = '' - else delete outer.dataset.filesPathClipped - } - apply() - const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(apply) - observer?.observe(outer) - observer?.observe(inner) - return () => { observer?.disconnect() } - }, [box, text, path]) -} -/* jscpd:ignore-end */ - /** What every level shares: the tab's tree and the two gestures. */ interface TreeContext { readonly state: FilesTabState @@ -175,11 +141,8 @@ export function FilesBody({ const { signal, actions: tabActions } = tab const cwd = useSessions(sessions => sessions.byId[sessionId]?.cwd) const state = useStore(store => store.byTab[tab.id]) - const pathRef = useRef(null) - const pathTextRef = useRef(null) const bodyRef = useRef(null) const scrollTopRef = useRef(0) - usePathClipped(pathRef, pathTextRef, state?.root) // Come back where the reader was: loaded levels outlive the body in the // store, so a remounted tree lays out at its full height before this runs // and the stored offset re-lands exactly. A fresh tree stores 0. @@ -222,17 +185,10 @@ export function FilesBody({ const reload = (): void => { refresh(tab.id) } - const { directory, name } = pathPartsOf(state.root) return (
- {/* jscpd:ignore-start -- the text preview's header row; see `usePathClipped`. */}
-
- - {directory !== '' && {directory}} - {name} - -
+
- {/* jscpd:ignore-end */}
{ expect(view.container.querySelector('[data-files-state="tree"]')?.getAttribute('data-files-root')).toBe(ROOT) const path = view.container.querySelector('[data-files-path]') expect(path?.getAttribute('title')).toBe(ROOT) - expect([...path?.querySelectorAll('span > span') ?? []].map(span => span.textContent)).toEqual(['/work/', 'app']) + expect([...path?.firstElementChild?.children ?? []].map(span => span.textContent)).toEqual(['/work/', 'app']) expect(names(view.container)).toEqual([`${ROOT}/src`, `${ROOT}/.env`, `${ROOT}/pipe`, `${ROOT}/README.md`]) const envIcon = view.container.querySelector(`[data-files-path="${ROOT}/.env"] svg`)?.innerHTML const readmeIcon = view.container.querySelector(`[data-files-path="${ROOT}/README.md"] svg`)?.innerHTML @@ -56,7 +56,7 @@ describe('FilesBody', () => { await act(() => script.watches.ready('/')) await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) const path = view.container.querySelector('[data-files-path]') - expect([...path?.querySelectorAll('span > span') ?? []].map(span => span.textContent)).toEqual(['/']) + expect([...path?.firstElementChild?.children ?? []].map(span => span.textContent)).toEqual(['/']) expect(names(view.container)).toEqual(['/src', '/.env', '/pipe', '/README.md']) }) @@ -86,7 +86,7 @@ describe('FilesBody', () => { await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) const path = view.container.querySelector('[data-files-path]') const text = path?.firstElementChild - expect(path?.hasAttribute('data-files-path-clipped')).toBe(false) + expect(path?.hasAttribute('data-path-clipped')).toBe(false) const observer = FakeResizeObserver.latest if (observer === undefined) throw new Error('expected the path to observe its size') expect(observer.observe).toHaveBeenCalledWith(path) @@ -94,11 +94,11 @@ describe('FilesBody', () => { boxWidth = 120 act(() => { observer.fire() }) - expect(path?.hasAttribute('data-files-path-clipped')).toBe(true) + expect(path?.hasAttribute('data-path-clipped')).toBe(true) boxWidth = 300 act(() => { observer.fire() }) - expect(path?.hasAttribute('data-files-path-clipped')).toBe(false) + expect(path?.hasAttribute('data-path-clipped')).toBe(false) view.unmount() expect(observer.disconnect).toHaveBeenCalledTimes(1) } finally { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index cc7c901ef3..0771d0ad57 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1567,6 +1567,18 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'productTelemetry', + summary: 'Host analytics sender.', + description: 'Host analytics sender. Mounting alone sends nothing; the owning fiber drains it on unload.', + methods: [ + { + signature: 'emit(record: ProductTelemetryRecord): void', + description: 'Enqueue one selected product event without waiting for network delivery. Queue admission and shutdown completion are not collector or warehouse acknowledgements.', + parameters: [{ name: 'record', description: 'caller-owned event containing only approved analytics fields.' }], + }, + ], + }, { key: 'profileContext', summary: 'Current profile facts; scheduling and mutation belong to their callers.', @@ -5582,6 +5594,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PreToolDecision', declaration: 'export type PreToolDecision = {\n kind: \'allow\';\n} | {\n kind: \'deny\';\n reason: string;\n info?: ToolErrorInfo;\n} | {\n kind: \'cancel\';\n} | {\n kind: \'ask\';\n reason?: string;\n};', }, + { + name: 'ProductTelemetryRecord', + declaration: 'export interface ProductTelemetryRecord {\n eventName: string;\n body: string;\n timestamp: number;\n severityNumber?: SeverityNumber;\n attributes?: Record>;\n}', + }, + { + name: 'ProductTelemetryScalar', + declaration: 'export type ProductTelemetryScalar = string | number | boolean;', + }, { name: 'ProfilePnpmInvocation', declaration: 'export interface ProfilePnpmInvocation {\n readonly command: string;\n readonly args: readonly string[];\n readonly env: Readonly>;\n}', diff --git a/packages/host/README.i18n.yaml b/packages/host/README.i18n.yaml index 0c7ec8a7e2..c45b78cfa1 100644 --- a/packages/host/README.i18n.yaml +++ b/packages/host/README.i18n.yaml @@ -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/host/README.md -README.md: 0b595eb7e63f33ed8e11ec0f39438f5d79b6ab33 -README.zh.md: 6a7507b7d8d2b2d791952783235a80280c8c0408 +README.md: f5e7832539404b8fb1fb856973f08d5dd5c87eb5 +README.zh.md: 402358f4f4097630ba8deda4551d6bc52ff01b11 diff --git a/packages/host/README.md b/packages/host/README.md index 0b595eb7e6..f5e7832539 100644 --- a/packages/host/README.md +++ b/packages/host/README.md @@ -1,5 +1,5 @@ --- -description: "Package map for the web GUI host half: the HTTP and SPA servers, workspace-directory picking implementations, the open-in-app launch routes, and the plugin inventory projection." +description: "Package map for the web GUI host half: the HTTP and SPA servers, workspace-directory picking implementations, the open-in-app launch routes, the plugin inventory projection, and explicit product event reporting." kind: "package-group" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The `host/` group provides the web GUI's plain HTTP server, the SPA dist server that serves the built Web shell, the workspace-directory picking seam with its native, browse, and adaptive composition packages, the open-in-app application probe and launch routes, and the read-only plugin inventory projection. All eight packages are product packages; the browser transport lives in [`client/`](../client/README.md), and the composed application is [`apps/cli`](../../apps/cli/README.md) booting the [`dsh-base` bundle](../bundle/base/cordis.patch.yml) that serves the web app under `apps/web/`. The picker backends replace one another behind the shared seam. +The `host/` group provides the web GUI's plain HTTP server, the SPA dist server that serves the built Web shell, the workspace-directory picking seam with its native, browse, and adaptive composition packages, the open-in-app application probe and launch routes, the read-only plugin inventory projection, and explicit product event reporting. These packages are product packages; the browser transport lives in [`client/`](../client/README.md), and the composed application is [`apps/cli`](../../apps/cli/README.md) booting the [`dsh-base` bundle](../bundle/base/cordis.patch.yml) that serves the web app under `apps/web/`. The picker backends replace one another behind the shared seam. ## Table of Contents @@ -22,7 +22,7 @@ The `host/` group provides the web GUI's plain HTTP server, the SPA dist server ## Packages -Eight packages play the host roles; each package README owns its contract and configuration. +The packages play the host roles; each package README owns its contract and configuration. | Package | Role | ctx key | |---|---|---| @@ -34,6 +34,7 @@ Eight packages play the host roles; each package README owns its contract and co | [`directory-picker-auto/`](directory-picker-auto/README.md) | Host-adaptive chooser that mounts the matching backend at boot | mounts a backend | | [`open-in-app/`](open-in-app/README.md) | Application probe, icon, and launch routes opening the workspace directory in an installed application | consumes `ctx.webServer` | | [`plugin-inventory/`](plugin-inventory/README.md) | Read-only projection of current Loader entries | Remote `pluginInventory/list` | +| [`product-telemetry-otel/`](product-telemetry-otel/README.md) | Explicit product usage events over OTLP/HTTP | `ctx.productTelemetry` | ----- @@ -45,6 +46,7 @@ Start with the subsystem references for the transport and the workspace records, - [HTTP server subsystem](../../docs/subsystems/web-server.md) — the webserver's routes, matching order, and config. - [Workspace subsystem](../../docs/subsystems/workspace.md) — the workspace records the directory picker feeds. - [Web config-tree boot and transport layering](../../.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md) — ownership of the Web transport layers. +- [Product telemetry](../../docs/subsystems/product-telemetry.md) — explicit analytics event submission. ## Dev Note diff --git a/packages/host/README.zh.md b/packages/host/README.zh.md index 6a7507b7d8..402358f4f4 100644 --- a/packages/host/README.zh.md +++ b/packages/host/README.zh.md @@ -1,5 +1,5 @@ --- -description: "Web GUI Host 侧的包映射:HTTP 与 SPA 服务器、工作区目录选择实现、open-in-app 启动路由和插件清单投影。" +description: "Web GUI Host 侧的包映射:HTTP 与 SPA 服务器、工作区目录选择实现、open-in-app 启动路由、插件清单投影和显式产品事件上报。" kind: "package-group" --- @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -`host/` 组提供 Web GUI 的普通 HTTP 服务器、服务已构建 Web 壳的 SPA dist 服务器、带原生/浏览/自适应组合包的工作区目录选择 seam、open-in-app 的应用探测与启动路由,以及只读的插件清单投影。这八个包都是产品包;浏览器传输位于 [`client/`](../client/README.zh.md),组合应用是 [`apps/cli`](../../apps/cli/README.zh.md),它启动 [`dsh-base` 组合包](../bundle/base/cordis.patch.yml) 来提供 `apps/web/` 下的 Web 应用。选择器后端可在共享 seam 后互相替换。 +`host/` 组提供 Web GUI 的普通 HTTP 服务器、服务已构建 Web 壳的 SPA dist 服务器、带原生/浏览/自适应组合包的工作区目录选择 seam、open-in-app 的应用探测与启动路由,只读的插件清单投影以及显式产品事件上报。这些包都是产品包;浏览器传输位于 [`client/`](../client/README.zh.md),组合应用是 [`apps/cli`](../../apps/cli/README.zh.md),它启动 [`dsh-base` 组合包](../bundle/base/cordis.patch.yml) 来提供 `apps/web/` 下的 Web 应用。选择器后端可在共享 seam 后互相替换。 ## 目录 @@ -22,7 +22,7 @@ kind: "package-group" ## 包 -八个包分别承担 Host 角色;各包的 README 拥有自己的约定与配置。 +各包分别承担 Host 角色;各包的 README 拥有自己的约定与配置。 | 包 | 职责 | ctx 键 | |---|---|---| @@ -34,6 +34,7 @@ kind: "package-group" | [`directory-picker-auto/`](directory-picker-auto/README.zh.md) | 在启动时挂载匹配后端的宿主自适应选择器 | 挂载一个后端 | | [`open-in-app/`](open-in-app/README.zh.md) | 在已安装应用中打开 workspace 目录的应用探测、图标与启动路由 | 消费 `ctx.webServer` | | [`plugin-inventory/`](plugin-inventory/README.zh.md) | 当前 Loader 条目的只读投影 | Remote `pluginInventory/list` | +| [`product-telemetry-otel/`](product-telemetry-otel/README.zh.md) | 通过 OTLP/HTTP 显式上报产品使用事件 | `ctx.productTelemetry` | ----- @@ -45,6 +46,7 @@ kind: "package-group" - [HTTP 服务器子系统](../../docs/subsystems/web-server.zh.md)——webserver 的路由、匹配顺序与配置。 - [工作区子系统](../../docs/subsystems/workspace.zh.md)——目录选择器所喂给的工作区记录。 - [Web 配置树启动与传输分层](../../.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)——Web 传输各层的所有权。 +- [产品埋点](../../docs/subsystems/product-telemetry.zh.md)——显式提交分析事件。 ## 开发备注 diff --git a/packages/host/product-telemetry-otel/README.i18n.yaml b/packages/host/product-telemetry-otel/README.i18n.yaml new file mode 100644 index 0000000000..ea4a841892 --- /dev/null +++ b/packages/host/product-telemetry-otel/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/host/product-telemetry-otel/README.md +README.md: af1f2c415fc21db874abc7bb0d9e25c7149fcabf +README.zh.md: ffdb682d9a8a0a4bf406f71322e062f14b666cf2 diff --git a/packages/host/product-telemetry-otel/README.md b/packages/host/product-telemetry-otel/README.md new file mode 100644 index 0000000000..af1f2c415f --- /dev/null +++ b/packages/host/product-telemetry-otel/README.md @@ -0,0 +1,108 @@ +--- +description: "Configure explicit product usage events, OTLP/HTTP routing, batching, and shutdown limits." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-host-product-telemetry-otel + +English | [中文](README.zh.md) + +## Summary + +Send selected product usage events to an OTLP/HTTP collector. Events carry a name, string summary, occurrence time, and scalar or one-level object attributes. Mounting the plugin collects nothing automatically; applications explicitly submit each event. Delivery is best effort and does not confirm warehouse ingestion. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Mount the plugin in a Cordis composition with the application identity; override the collector endpoint when needed. The shipped profiles do not mount it. Set `DSH_APP_VERSION` to the running application release version in the launcher environment; the schema rejects an absent version. + +```yaml +- name: '@deepseek-ai/dsh-host-product-telemetry-otel' + config: + endpoint: https://dsh-otel-collector.deepseeksvc.com/v1/logs + serviceName: deepseek-harness + serviceVersion: !!js process.env.DSH_APP_VERSION + compression: gzip + scheduledDelayMillis: 30000 +``` + +| Field | Default | Meaning | +|---|---|---| +| `endpoint` | `https://dsh-otel-collector.deepseeksvc.com/v1/logs` | Full HTTP(S) logs URL | +| `serviceName`, `serviceVersion` | required | Application identity on the OTel resource | +| `channel` | `dsh_otel_report` | Collector `x-channel` header | +| `compression` | SDK environment | `gzip` or `none`; omission honors OTel compression environment variables | +| `maxExportBatchSize`, `maxQueueSize` | `512`, `2048` | Record-count limits; batch size cannot exceed queue size | +| `scheduledDelayMillis` | `30000` | Partial-batch export interval | +| `timeoutMillis` | `15000` | Exporter HTTP and retry deadline | +| `exportTimeoutMillis` | `20000` | Processor batch export deadline | +| `shutdownTimeoutMillis` | `21000` | Outer wait for shutdown; expiry reports possible loss | + +The default endpoint routes explicitly submitted events to the production product collector. Test and custom deployments must override it. Only `x-channel` and SDK protocol headers reach the collector; ambient OTel headers and client certificates are not inherited. + +The 30-second interval batches product events; the exporter has a 15-second retry window inside the processor’s 20-second batch deadline. The outer 21-second wait bounds plugin disposal, including SDK `forceFlush()` work that the processor deadline does not cover. An unreachable collector can delay disposal for the full 21 seconds. A full 2,048-record queue requires four 512-record batches and may not drain before that deadline. Interactive compositions needing a shorter exit should override these budgets; neither configuration guarantees delivery. + +Consumers inject `productTelemetry` and call `emit()` with explicitly selected analytics fields. Event names and field semantics belong to their product and analytics owners. The plugin reads no Session, account, credential, or device identifier. Callers must exclude prompts, responses, file contents, credentials, and other unapproved values. + +The collector expects a string body and attributes containing strings, numbers, booleans, or objects of those scalars. Callers supply occurrence time in milliseconds; the plugin assigns observation time and defaults severity to INFO. Invalid transport configuration fails at activation. Export failures produce local warnings without making event submission wait for the network. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +A private OTel logger feeds `BatchLogRecordProcessor` and the SDK HTTP delegate with its JSON log serializer. The delegate receives explicit headers and an HTTP agent; only shared timeout and compression settings use SDK environment resolution. The direct `@opentelemetry/core` dependency matches `sdk-logs` at 2.9.0 so exporter result enums share one TypeScript identity. The SDK owns queueing, transient-error retries, and compression; plugin disposal drains pending records with a bounded wait. Export completion is observed separately because SDK shutdown can resolve after a rejected export. No global OTel provider is installed. + +[`src/index.ts`](src/index.ts) owns configuration and submission. No runtime invariant companion is published: delivery has no independent local acknowledgement to compare with the SDK's queue. + +
+ +----- + + +## Further Exploration + +- [Product telemetry](../../../docs/subsystems/product-telemetry.md) — consumer types and service reference. +- [Session telemetry](../../session/session-telemetry-otel/README.md) — separate feedback-authorized Session reporting. +- [Testing policy](../../../docs/testing.md) — Loader composition and network fixtures. + +----- + + +## Model Experience + +None, as the plugin exports explicit analytics records without contributing model context. + +#### KV Cache effect + +None; event submission does not change model requests. + +## Known Limitations and Deferred Work + + + +Delivery and collection remain limited to the following capabilities. + +- Callers own event selection, renderer-to-host transport, and any permitted identity attributes. +- The queue is memory-only; overflow, network failure, and process exit can lose events. There is no durable outbox or warehouse acknowledgement. +- The SDK batches by record count, not encoded bytes. Callers must keep records within the collector's 4 MB limit and choose batch sizes appropriate to the receiver. +- Caller-selected strings are not redacted automatically. This package does not decide product disclosure or consent policy. + + +### Dev Note + +None. diff --git a/packages/host/product-telemetry-otel/README.zh.md b/packages/host/product-telemetry-otel/README.zh.md new file mode 100644 index 0000000000..ffdb682d9a --- /dev/null +++ b/packages/host/product-telemetry-otel/README.zh.md @@ -0,0 +1,108 @@ +--- +description: "配置显式产品使用事件、OTLP/HTTP 路由、批量发送与退出等待上限。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-host-product-telemetry-otel + +[English](README.md) | 中文 + +## 概述 + +将选定的产品使用事件发送到 OTLP/HTTP 接收服务。事件包含名称、字符串摘要、发生时间,以及标量或单层对象属性。挂载插件不会自动采集信息;应用需要显式提交每个事件。发送采用尽力而为方式,不代表数据已入仓。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [延伸阅读](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与待办工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +在 Cordis 组合中挂载插件并提供应用标识;需要时可覆盖接收地址。内置 profile 不挂载本插件。启动器环境须将 `DSH_APP_VERSION` 设为运行中应用的发布版本;缺少版本时 schema 会拒绝配置。 + +```yaml +- name: '@deepseek-ai/dsh-host-product-telemetry-otel' + config: + endpoint: https://dsh-otel-collector.deepseeksvc.com/v1/logs + serviceName: deepseek-harness + serviceVersion: !!js process.env.DSH_APP_VERSION + compression: gzip + scheduledDelayMillis: 30000 +``` + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `endpoint` | `https://dsh-otel-collector.deepseeksvc.com/v1/logs` | 完整 HTTP(S) 日志地址 | +| `serviceName`, `serviceVersion` | 必填 | OTel resource 中的应用标识 | +| `channel` | `dsh_otel_report` | 接收服务的 `x-channel` 请求头 | +| `compression` | SDK 环境变量 | `gzip` 或 `none`;省略时遵循 OTel 压缩环境变量 | +| `maxExportBatchSize`, `maxQueueSize` | `512`, `2048` | 记录条数上限;批次大小不能超过队列大小 | +| `scheduledDelayMillis` | `30000` | 不满批次时的发送间隔 | +| `timeoutMillis` | `15000` | Exporter HTTP 发送与重试的超时时间 | +| `exportTimeoutMillis` | `20000` | Processor 批次导出的超时时间 | +| `shutdownTimeoutMillis` | `21000` | 退出时的等待上限;超时会提示可能丢失数据 | + +默认接收地址将显式提交的事件发送到生产产品 collector,测试和自定义部署必须覆盖该地址。只向 collector 发送 `x-channel` 和 SDK 协议请求头,不继承宿主 OTel 请求头或客户端证书。 + +30 秒间隔用于批量发送产品事件;exporter 的 15 秒重试窗口位于 processor 的 20 秒批次期限内。外层 21 秒等待限制插件卸载时间,包括 processor 期限未覆盖的 SDK `forceFlush()`。collector 不可达时,卸载可能等待完整的 21 秒。2,048 条满队列需要四个 512 条批次,可能无法在期限前排空。要求更快退出的交互式应用组合应覆盖这些时间配置;两种配置都不保证送达。 + +消费方注入 `productTelemetry`,调用 `emit()` 提交明确选定的分析字段。事件名称与字段含义由产品和数据分析负责人定义。插件不读取 Session、账号、凭证或设备标识。调用方必须排除提示词、回答、文件内容、凭证及其他未经批准的数据。 + +接收服务要求 body 为字符串,attributes 的值为字符串、数字、布尔值或由这些标量组成的对象。调用方以毫秒提供发生时间;插件填写观测时间,默认严重程度为 INFO。无效传输配置在激活时报错。发送失败产生本地警告,事件提交不等待网络。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +私有 OTel logger 将记录交给 `BatchLogRecordProcessor`,再由 SDK HTTP delegate 和 JSON 日志序列化器发送。delegate 使用显式请求头和 HTTP agent,只有共享的超时与压缩配置使用 SDK 环境变量解析。直接依赖的 `@opentelemetry/core` 与 `sdk-logs` 对齐为 2.9.0,使导出结果枚举共享同一个 TypeScript 类型身份。SDK 负责队列、临时错误重试与压缩;插件卸载时在限定时间内发送剩余记录。发送完成结果单独观测,因为 SDK 在发送被拒绝后仍可能正常完成退出。不安装全局 OTel provider。 + +[`src/index.ts`](src/index.ts) 负责配置与提交。不发布运行时不变量伴随模块:本地没有独立的送达确认可与 SDK 队列比较。 + +
+ +----- + + +## 延伸阅读 + +- [产品埋点](../../../docs/subsystems/product-telemetry.zh.md)——消费方类型与服务参考。 +- [会话上报](../../session/session-telemetry-otel/README.zh.md)——独立的、由反馈授权的 Session 上报。 +- [测试策略](../../../docs/testing.zh.md)——Loader 组合与网络测试夹具。 + +----- + + +## 模型体验 + +无,因为插件仅发送显式分析记录,不提供模型上下文。 + +#### KV Cache 影响 + +无;提交事件不会改变模型请求。 + +## 已知限制与待办工作 + + + +发送和采集受以下能力限制。 + +- 调用方拥有事件选择、渲染进程到宿主的传输以及获准使用的标识属性。 +- 队列仅存在于内存;队列溢出、网络故障和进程退出可能丢失事件。没有持久发件箱或入仓确认。 +- SDK 按记录条数而非编码字节数分批。调用方必须确保记录不超过接收服务的 4 MB 限制,并选择接收服务适用的批次大小。 +- 不自动脱敏调用方选择的字符串。本包不决定产品告知或同意策略。 + + +### 开发备注 + +无。 diff --git a/packages/host/product-telemetry-otel/package.json b/packages/host/product-telemetry-otel/package.json new file mode 100644 index 0000000000..2bcdeb691b --- /dev/null +++ b/packages/host/product-telemetry-otel/package.json @@ -0,0 +1,48 @@ +{ + "name": "@deepseek-ai/dsh-host-product-telemetry-otel", + "description": "Explicit product usage events exported through OpenTelemetry HTTP logs", + "version": "0.1.6-alpha.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/host/product-telemetry-otel" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/api-logs": "^0.220.0", + "@opentelemetry/core": "2.9.0", + "@opentelemetry/otlp-exporter-base": "^0.220.0", + "@opentelemetry/otlp-transformer": "^0.220.0", + "@opentelemetry/resources": "^2.9.0", + "@opentelemetry/sdk-logs": "^0.220.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^" + } +} diff --git a/packages/host/product-telemetry-otel/src/index.ts b/packages/host/product-telemetry-otel/src/index.ts new file mode 100644 index 0000000000..eef6ec5dce --- /dev/null +++ b/packages/host/product-telemetry-otel/src/index.ts @@ -0,0 +1,171 @@ +/** Explicit product usage events over OTLP/HTTP; no automatic collection or Session access. */ +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { SeverityNumber, type Logger } from '@opentelemetry/api-logs' +import { validateHeaderValue } from 'node:http' +import { JsonLogsSerializer } from '@opentelemetry/otlp-transformer' +import { createOtlpHttpExportDelegate, getSharedConfigurationFromEnvironment, httpAgentFactoryFromOptions } from '@opentelemetry/otlp-exporter-base/node-http' +import { CompressionAlgorithm, getSharedConfigurationDefaults, mergeOtlpSharedConfigurationWithDefaults, OTLPExporterBase } from '@opentelemetry/otlp-exporter-base' +import { ExportResultCode } from '@opentelemetry/core' +import { resourceFromAttributes } from '@opentelemetry/resources' +import { BatchLogRecordProcessor, LoggerProvider } from '@opentelemetry/sdk-logs' + +declare module '@deepseek-ai/cordis' { + interface Context { + productTelemetry: ProductTelemetry + } +} + +/** Scalar values accepted by the collector's Arrow attributes map. */ +export type ProductTelemetryScalar = string | number | boolean + +/** Explicitly selected analytics fields; object values may contain scalars only. */ +export interface ProductTelemetryRecord { + /** Product/DA-owned event name. */ + eventName: string + /** Human-readable summary; never a prompt, response, credential, or file contents. */ + body: string + /** Event occurrence time in Unix milliseconds. Observation time is assigned on enqueue. */ + timestamp: number + /** OTel severity; omitted values use INFO. */ + severityNumber?: SeverityNumber + /** Business fields selected by the caller; no automatic device or account identity. */ + attributes?: Record> +} + +/** Collector routing, application identity, and bounded in-memory batch settings. */ +export interface Config { + /** Full HTTP(S) logs URL. */ + endpoint: string + /** Collector routing header. */ + channel: string + /** Resource service.name supplied by the application composition. */ + serviceName: string + /** Resource service.version supplied by the application composition. */ + serviceVersion: string + /** Omit to honor OTEL_EXPORTER_OTLP_LOGS_COMPRESSION / OTEL_EXPORTER_OTLP_COMPRESSION. */ + compression?: 'none' | 'gzip' + /** Maximum records per export; must not exceed maxQueueSize. */ + maxExportBatchSize: number + /** Maximum queued records; the SDK drops new records when full. */ + maxQueueSize: number + /** Delay before exporting a partial batch. */ + scheduledDelayMillis: number + /** Exporter HTTP deadline, including SDK transient-error retries. */ + timeoutMillis: number + /** Processor deadline for one batch export. */ + exportTimeoutMillis: number + /** Outer shutdown wait; pending exports may be lost after this deadline. */ + shutdownTimeoutMillis: number +} + +const positiveInteger = () => z.number().step(1).min(1).max(2_147_483_647) + +/** Loader validation and defaults for application compositions. */ +export const Config: z, Config> = z.object({ + endpoint: z.string().default('https://dsh-otel-collector.deepseeksvc.com/v1/logs'), + channel: z.string().min(1).default('dsh_otel_report'), + serviceName: z.string().required(), + serviceVersion: z.string().required(), + compression: z.union(['none', 'gzip']), + maxExportBatchSize: positiveInteger().default(512), + maxQueueSize: positiveInteger().default(2048), + scheduledDelayMillis: positiveInteger().default(30000), + timeoutMillis: positiveInteger().default(15000), + exportTimeoutMillis: positiveInteger().default(20000), + shutdownTimeoutMillis: positiveInteger().default(21000), +}) + +/** Host analytics sender. Mounting alone sends nothing; the owning fiber drains it on unload. */ +export default class ProductTelemetry extends Service { + static Config = Config + private readonly logger: Logger + + constructor(ctx: Context, config: Config) { + let endpoint: URL + try { + endpoint = new URL(config.endpoint) + } catch (cause) { + throw new Error('product-telemetry-otel: endpoint must be a valid HTTP(S) URL', { cause }) + } + try { + validateHeaderValue('x-channel', config.channel) + } catch (cause) { + throw new Error('product-telemetry-otel: channel must be a valid HTTP header value', { cause }) + } + if (!['http:', 'https:'].includes(endpoint.protocol)) { + throw new Error('product-telemetry-otel: endpoint must use HTTP or HTTPS') + } + if (config.maxExportBatchSize > config.maxQueueSize) { + throw new Error('product-telemetry-otel: maxExportBatchSize must not exceed maxQueueSize') + } + super(ctx, 'productTelemetry') + const shared = mergeOtlpSharedConfigurationWithDefaults({ + timeoutMillis: config.timeoutMillis, + ...(config.compression === undefined ? {} : { + compression: config.compression === 'gzip' ? CompressionAlgorithm.GZIP : CompressionAlgorithm.NONE, + }), + }, getSharedConfigurationFromEnvironment('LOGS'), getSharedConfigurationDefaults()) + const transport = { + ...shared, + url: config.endpoint, + // This collector must not inherit another endpoint's headers or TLS client identity. + headers: () => Promise.resolve({ 'Content-Type': 'application/json', 'x-channel': config.channel }), + agentFactory: httpAgentFactoryFromOptions({ keepAlive: true }), + } + const exporter = new OTLPExporterBase(createOtlpHttpExportDelegate(transport, JsonLogsSerializer)) + const provider = new LoggerProvider({ + resource: resourceFromAttributes({ + 'service.name': config.serviceName, + 'service.version': config.serviceVersion, + }), + processors: [new BatchLogRecordProcessor({ + maxExportBatchSize: config.maxExportBatchSize, + maxQueueSize: config.maxQueueSize, + scheduledDelayMillis: config.scheduledDelayMillis, + exportTimeoutMillis: config.exportTimeoutMillis, + exporter: { + export: (records, callback) => { + exporter.export(records, (result) => { + // SDK flush/shutdown can resolve after export failure; observe the actual completion. + if (result.code !== ExportResultCode.SUCCESS) ctx.logger.warn('Product telemetry export failed', result.error) + callback(result) + }) + }, + forceFlush: () => exporter.forceFlush(), + shutdown: () => exporter.shutdown(), + }, + })], + }) + this.logger = provider.getLogger('@deepseek-ai/dsh-host-product-telemetry-otel') + ctx.effect(() => async () => { + let timer!: ReturnType + const deadline = new Promise((resolve) => { + timer = setTimeout(() => { + ctx.logger.warn('Product telemetry shutdown deadline exceeded; pending events may be lost') + resolve() + }, config.shutdownTimeoutMillis) + }) + try { + await Promise.race([provider.shutdown(), deadline]) + } finally { + clearTimeout(timer) + } + }) + } + + /** + * Enqueue one selected product event without waiting for network delivery. + * Queue admission and shutdown completion are not collector or warehouse acknowledgements. + * @param record - caller-owned event containing only approved analytics fields. + */ + emit(record: ProductTelemetryRecord): void { + const severityNumber = record.severityNumber ?? SeverityNumber.INFO + this.logger.emit({ + ...record, + observedTimestamp: Date.now(), + severityNumber, + severityText: SeverityNumber[severityNumber], + }) + } +} diff --git a/packages/host/product-telemetry-otel/tests/fixtures/driver.ts b/packages/host/product-telemetry-otel/tests/fixtures/driver.ts new file mode 100644 index 0000000000..a0ad9389d8 --- /dev/null +++ b/packages/host/product-telemetry-otel/tests/fixtures/driver.ts @@ -0,0 +1,43 @@ +/** Loader composition with a loopback collector and one explicit synthetic event. */ +import { createServer } from 'node:http' +import { once } from 'node:events' +import { gunzipSync } from 'node:zlib' +import { writeFile } from 'node:fs/promises' +import type {} from '@deepseek-ai/dsh-host-product-telemetry-otel' +import { bootProductionProfile } from '../../../../test-support/loader-smoke/tests/fixtures/production-profile.ts' + +const configPath = process.argv[2] +if (configPath === undefined) throw new Error('product telemetry fixture requires an overlay') +const captures: unknown[] = [] +const server = createServer((req, res) => { + const chunks: Buffer[] = [] + req.on('data', chunk => chunks.push(chunk as Buffer)) + req.on('end', () => { + const bytes = Buffer.concat(chunks) + const body: unknown = JSON.parse(gunzipSync(bytes).toString()) + captures.push({ channel: req.headers['x-channel'], compression: req.headers['content-encoding'], body }) + res.writeHead(200, { 'content-type': 'application/json' }).end('{}') + }) +}) +try { + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('collector has no port') + process.env.DSH_APP_VERSION = 'synthetic-release' + process.env.DSH_PRODUCT_TELEMETRY_TEST_ENDPOINT = `http://127.0.0.1:${address.port}/v1/logs` + const ctx = await bootProductionProfile({ binName: 'product-telemetry-test', profile: 'headless', overlayPaths: [configPath] }) + try { + const telemetry = ctx.get('productTelemetry') + if (telemetry === undefined) throw new Error('product telemetry did not activate') + telemetry.emit({ eventName: 'telemetry.synthetic', timestamp: 1_800_000_000_000, body: 'Synthetic test', attributes: { synthetic: true } }) + } finally { + await ctx.fiber.dispose() + } + await writeFile('captures.json', JSON.stringify(captures)) +} finally { + const closed = once(server, 'close') + server.close() + server.closeAllConnections() + await closed +} diff --git a/packages/host/product-telemetry-otel/tests/fixtures/telemetry.patch.yml b/packages/host/product-telemetry-otel/tests/fixtures/telemetry.patch.yml new file mode 100644 index 0000000000..d9104a0bfb --- /dev/null +++ b/packages/host/product-telemetry-otel/tests/fixtures/telemetry.patch.yml @@ -0,0 +1,15 @@ +- id: session-telemetry-otel + disabled: true +- id: headless-startup + disabled: true +- id: headless-runner + disabled: true +- insert: + - id: product-telemetry-otel + name: '@deepseek-ai/dsh-host-product-telemetry-otel' + config: + endpoint: !!js process.env.DSH_PRODUCT_TELEMETRY_TEST_ENDPOINT + serviceName: dsh-telemetry-test + serviceVersion: !!js process.env.DSH_APP_VERSION + compression: gzip + scheduledDelayMillis: 60000 diff --git a/packages/host/product-telemetry-otel/tests/loader-composition.e2e.ts b/packages/host/product-telemetry-otel/tests/loader-composition.e2e.ts new file mode 100644 index 0000000000..13acb46e00 --- /dev/null +++ b/packages/host/product-telemetry-otel/tests/loader-composition.e2e.ts @@ -0,0 +1,23 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { expect, it } from 'vitest' +import { runLoaderSmoke, LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke' + +it('exports the explicitly submitted event through the headless Loader composition', async () => { + let captures: unknown + const driver = resolve(import.meta.dirname, 'fixtures/driver.ts') + await runLoaderSmoke({ + label: 'product telemetry composition', + tempDirPrefix: 'product-telemetry-', + binScript: driver, + libBinScript: driver, + configPath: resolve(import.meta.dirname, 'fixtures/telemetry.patch.yml'), + tsconfigPath: resolve(import.meta.dirname, '../../../../tsconfig.base.json'), + inspect: async (cwd) => { captures = JSON.parse(await readFile(resolve(cwd, 'captures.json'), 'utf8')) }, + }) + expect(captures).toMatchObject([{ + channel: 'dsh_otel_report', compression: 'gzip', + body: { resourceLogs: [{ scopeLogs: [{ logRecords: [{ eventName: 'telemetry.synthetic', body: { stringValue: 'Synthetic test' } }] }] }] }, + }]) + expect(JSON.stringify(captures).match(/"eventName"/g)).toHaveLength(1) +}, LOADER_SMOKE_TEST_TIMEOUT_MS) diff --git a/packages/host/product-telemetry-otel/tests/telemetry.spec.ts b/packages/host/product-telemetry-otel/tests/telemetry.spec.ts new file mode 100644 index 0000000000..22f7df5c30 --- /dev/null +++ b/packages/host/product-telemetry-otel/tests/telemetry.spec.ts @@ -0,0 +1,203 @@ +import { createServer, type IncomingHttpHeaders } from 'node:http' +import { once } from 'node:events' +import { gunzipSync } from 'node:zlib' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { LoggerProvider } from '@opentelemetry/sdk-logs' +import { SeverityNumber } from '@opentelemetry/api-logs' +import ProductTelemetry, { Config } from '../src/index.ts' + +interface Capture { + headers: IncomingHttpHeaders + body: { resourceLogs: { resource: unknown; scopeLogs: { logRecords: Record[] }[] }[] } +} +const cleanup: (() => Promise)[] = [] +afterEach(async () => { + try { + for (const dispose of cleanup.splice(0).reverse()) await dispose() + } finally { + vi.unstubAllEnvs() + vi.restoreAllMocks() + vi.useRealTimers() + } +}) + +beforeEach(() => { + vi.stubEnv('OTEL_EXPORTER_OTLP_COMPRESSION', undefined) + vi.stubEnv('OTEL_EXPORTER_OTLP_LOGS_COMPRESSION', undefined) +}) + +async function collector(statuses = [200]) { + const captures: Capture[] = [] + const server = createServer((req, res) => { + const chunks: Buffer[] = [] + req.on('data', chunk => chunks.push(chunk as Buffer)) + req.on('end', () => { + const raw = Buffer.concat(chunks) + const bytes = req.headers['content-encoding'] === 'gzip' ? gunzipSync(raw) : raw + captures.push({ headers: req.headers, body: JSON.parse(bytes.toString()) as Capture['body'] }) + res.writeHead(statuses.shift() ?? 200, { 'content-type': 'application/json' }).end('{}') + }) + }) + cleanup.push(async () => { + const closed = once(server, 'close') + server.close() + server.closeAllConnections() + await closed + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('collector has no port') + return { captures, endpoint: `http://127.0.0.1:${address.port}/v1/logs` } +} + +function config(endpoint: string, overrides: Partial = {}): Config { + return Config({ endpoint, serviceName: 'synthetic-test', serviceVersion: '1', scheduledDelayMillis: 60_000, ...overrides }) +} + +function context() { + const ctx = new Context() + cleanup.push(() => ctx.fiber.dispose()) + return ctx +} + +const event = { eventName: 'telemetry.synthetic', body: 'Synthetic test', timestamp: 1_800_000_000_000 } + +describe('explicit product telemetry', () => { + it.each(['gzip', 'none', undefined] as const)('drains typed events with %s compression and removes the service', async (compression) => { + const target = await collector() + const ctx = context() + const fiber = await ctx.plugin(ProductTelemetry, config(target.endpoint, compression === undefined ? {} : { compression })) + const service = ctx.productTelemetry + expect(target.captures).toEqual([]) + service.emit({ ...event, attributes: { text: 'test', count: 2, duration: 1.5, synthetic: true, model: { provider: 'test', count: 1, enabled: false } } }) + service.emit({ ...event, severityNumber: SeverityNumber.ERROR }) + await fiber.dispose() + expect(ctx.get('productTelemetry')).toBeUndefined() + service.emit(event) + expect(target.captures).toHaveLength(1) + const [capture] = target.captures + expect(capture?.headers['x-channel']).toBe('dsh_otel_report') + expect(capture?.headers['content-encoding']).toBe(compression === 'gzip' ? 'gzip' : undefined) + const logs = capture?.body.resourceLogs.flatMap(r => r.scopeLogs.flatMap(s => s.logRecords)) + expect(logs).toHaveLength(2) + expect(logs?.[0]).toMatchObject({ + eventName: event.eventName, body: { stringValue: event.body }, severityNumber: 9, + timeUnixNano: '1800000000000000000', + }) + expect(logs?.[0]?.['attributes']).toEqual(expect.arrayContaining([ + { key: 'text', value: { stringValue: 'test' } }, + { key: 'count', value: { intValue: 2 } }, + { key: 'duration', value: { doubleValue: 1.5 } }, + { key: 'synthetic', value: { boolValue: true } }, + { key: 'model', value: { kvlistValue: { values: [{ key: 'provider', value: { stringValue: 'test' } }, { key: 'count', value: { intValue: 1 } }, { key: 'enabled', value: { boolValue: false } }] } } }, + ])) + expect(Number(logs?.[0]?.['observedTimeUnixNano'])).toBeGreaterThan(0) + expect(logs?.[1]?.['severityNumber']).toBe(17) + expect(JSON.stringify(capture)).not.toContain('user.id') + }) + + it('isolates collector headers from ambient OpenTelemetry credentials', async () => { + vi.stubEnv('OTEL_EXPORTER_OTLP_HEADERS', 'Authorization=Bearer%20synthetic-secret,x-user-id=synthetic-user') + vi.stubEnv('OTEL_EXPORTER_OTLP_LOGS_HEADERS', 'x-log-token=synthetic-token,x-channel=other-collector') + const target = await collector() + const ctx = context() + const fiber = await ctx.plugin(ProductTelemetry, config(target.endpoint)) + ctx.productTelemetry.emit(event) + await fiber.dispose() + expect(target.captures).toHaveLength(1) + expect(target.captures[0]?.headers).toMatchObject({ 'x-channel': 'dsh_otel_report' }) + expect(target.captures[0]?.headers).not.toHaveProperty('authorization') + expect(target.captures[0]?.headers).not.toHaveProperty('x-user-id') + expect(target.captures[0]?.headers).not.toHaveProperty('x-log-token') + expect(process.env['OTEL_EXPORTER_OTLP_HEADERS']).toContain('synthetic-secret') + }) + + it('does not export on mount or empty shutdown', async () => { + const target = await collector() + const fiber = await context().plugin(ProductTelemetry, config(target.endpoint)) + await fiber.dispose() + expect(target.captures).toEqual([]) + }) + + it('exports at the batch threshold and retries a transient rejection', async () => { + const target = await collector([503, 200]) + const ctx = context() + await ctx.plugin(ProductTelemetry, config(target.endpoint, { maxExportBatchSize: 1 })) + ctx.productTelemetry.emit(event) + await vi.waitFor(() => { expect(target.captures).toHaveLength(2) }, { timeout: 10_000 }) + expect(target.captures[0]?.body).toEqual(target.captures[1]?.body) + }) + + it('exports a partial batch on its configured interval', async () => { + const target = await collector() + const ctx = context() + await ctx.plugin(ProductTelemetry, config(target.endpoint, { scheduledDelayMillis: 10 })) + ctx.productTelemetry.emit(event) + await vi.waitFor(() => { expect(target.captures).toHaveLength(1) }) + }) + + it('reports a permanent export rejection without failing the caller', async () => { + const target = await collector([400]) + const ctx = context() + const warn = vi.spyOn(ctx.logger, 'warn') + const fiber = await ctx.plugin(ProductTelemetry, config(target.endpoint)) + expect(() => { ctx.productTelemetry.emit(event) }).not.toThrow() + await fiber.dispose() + expect(target.captures).toHaveLength(1) + expect(warn).toHaveBeenCalledWith('Product telemetry export failed', expect.any(Error)) + }) + + it.each([ + { endpoint: 'broken' }, { endpoint: 'ftp://collector.test/logs' }, + { channel: '' }, { channel: 'bad\nchannel' }, { channel: '中文' }, { timeoutMillis: 0 }, + { maxExportBatchSize: 0 }, { maxExportBatchSize: 2, maxQueueSize: 1 }, + { maxQueueSize: -1 }, { scheduledDelayMillis: 0 }, { exportTimeoutMillis: Infinity }, + { shutdownTimeoutMillis: 2_147_483_648 }, + ])('rejects invalid configuration %j before exposing the service', (invalid) => { + const ctx = context() + expect(() => new ProductTelemetry(ctx, config('http://collector.test/v1/logs', invalid))).toThrow() + expect(ctx.get('productTelemetry')).toBeUndefined() + }) + + it.each([ + [{ endpoint: 'broken' }, 'endpoint must be a valid HTTP(S) URL'], + [{ channel: 'bad\nchannel' }, 'channel must be a valid HTTP header value'], + ] as const)('names invalid transport fields before registering the service', (invalid, message) => { + const ctx = context() + expect(() => new ProductTelemetry(ctx, config('http://collector.test/v1/logs', invalid))) + .toThrow(`product-telemetry-otel: ${message}`) + expect(ctx.get('productTelemetry')).toBeUndefined() + }) + + it.each([ + ['OTEL_EXPORTER_OTLP_COMPRESSION', 'gzip'], + ['OTEL_EXPORTER_OTLP_LOGS_COMPRESSION', 'gzip'], + ])('honors %s when compression is omitted', async (name, value) => { + vi.stubEnv(name, value) + const target = await collector() + const ctx = context() + const fiber = await ctx.plugin(ProductTelemetry, config(target.endpoint)) + ctx.productTelemetry.emit(event) + await fiber.dispose() + expect(target.captures[0]?.headers['content-encoding']).toBe('gzip') + }) + + it('bounds a stalled shutdown and observes its later settlement', async () => { + const target = await collector() + const ctx = context() + const warn = vi.spyOn(ctx.logger, 'warn') + const fiber = await ctx.plugin(ProductTelemetry, config(target.endpoint, { shutdownTimeoutMillis: 20 })) + const pending = Promise.withResolvers() + vi.spyOn(LoggerProvider.prototype, 'shutdown').mockReturnValue(pending.promise) + vi.useFakeTimers() + const disposal = fiber.dispose() + await vi.advanceTimersByTimeAsync(20) + await disposal + expect(warn).toHaveBeenCalledWith('Product telemetry shutdown deadline exceeded; pending events may be lost') + pending.resolve(undefined) + await pending.promise + await vi.advanceTimersByTimeAsync(0) + }) +}) diff --git a/packages/host/product-telemetry-otel/tsconfig.json b/packages/host/product-telemetry-otel/tsconfig.json new file mode 100644 index 0000000000..f0063ae28d --- /dev/null +++ b/packages/host/product-telemetry-otel/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ffe4df02d..db1cd2d495 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -671,6 +671,9 @@ importers: '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../packages/test-support/client-runtime + '@deepseek-ai/dsh-client-ui-deliverables': + specifier: workspace:^ + version: link:../../packages/client/ui-deliverables '@deepseek-ai/dsh-client-ui-dockkit': specifier: workspace:^ version: link:../../packages/client/ui-dockkit @@ -4053,6 +4056,9 @@ importers: '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../store + '@deepseek-ai/dsh-util-workspace-path': + specifier: workspace:^ + version: link:../../util/workspace-path '@shikijs/langs': specifier: ^4.3.1 version: 4.3.1 @@ -8436,6 +8442,46 @@ importers: specifier: workspace:^ version: link:../../typert/protocol + packages/host/product-telemetry-otel: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + '@opentelemetry/api': + specifier: ^1.9.1 + version: 1.9.1 + '@opentelemetry/api-logs': + specifier: ^0.220.0 + version: 0.220.0 + '@opentelemetry/core': + specifier: 2.9.0 + version: 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': + specifier: ^0.220.0 + version: 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': + specifier: ^0.220.0 + version: 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': + specifier: ^2.9.0 + version: 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': + specifier: ^0.220.0 + version: 0.220.0(@opentelemetry/api@1.9.1) + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../../boot/app-boot + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../test-support/loader-smoke + packages/host/webserver: dependencies: '@deepseek-ai/schemastery': diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 454c58b734..91a4782497 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -56,6 +56,7 @@ export { REGION_BEGIN, REGION_END } export const SERVICE_PAGE: Record = { speechToText: 'voice-input.md', speechController: 'voice-input.md', + productTelemetry: 'product-telemetry.md', connection: 'web-server.md', pluginManager: 'boot.md', profileContext: 'boot.md', @@ -266,6 +267,8 @@ export const EVENT_WALK_EXEMPTIONS: Record = { * appear on more than one page. */ export const LINK_MAP: Readonly> = { + ProductTelemetryRecord: 'product-telemetry.md', + ProductTelemetryScalar: 'product-telemetry.md', WorkspaceChangesSummary: 'deliverables.md', WorkspaceFileDiff: 'deliverables.md', Reload: 'boot.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index e33aeaffcb..0890e45bd7 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -31,7 +31,7 @@ interface ServiceRole { key: string pkg: string title: string - mode: 'core' | 'seam' | 'bundle' + mode: 'core' | 'seam' | 'bundle' | 'service' implementations?: string[] consumers?: string[] companions?: string[] @@ -373,6 +373,14 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['llm-pi-ai'], note: 'Flows are registered by the plugin that knows how to obtain one credential and keyed by the record they write; the seam owns the conversation and the one-attempt-per-key lifecycle, never the protocol.', }, + { + key: 'productTelemetry', + pkg: 'host-product-telemetry-otel', + title: 'Product usage event sender', + mode: 'service', + note: 'Exports explicitly submitted analytics events through OTLP/HTTP; mounting alone collects nothing.', + }, + { key: 'sessionTelemetry', pkg: 'session-telemetry', @@ -863,7 +871,7 @@ function renderCapabilitySeams(pkgs: Pkg[], services: readonly ServiceEntry[]): const addEdge = (from: string, to: string): void => { edges.add(` ${from} --> ${to}`) } const lines = generatedHeader('Capability Seams And Core Services') lines.push( - 'A service can be a core spine service, a swappable capability seam, or a bundle/composition point. The graph shows the package that owns the service declaration, known implementation packages, and packages that consume the service directly.', + 'A service can be a core spine service, a swappable capability seam, a bundle/composition point, or a standalone service. The graph shows the package that owns the service declaration, known implementation packages, and packages that consume the service directly.', '', '```mermaid', 'flowchart LR', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index e36b61fbf7..e1bf5ad4e9 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -2256,6 +2256,16 @@ "doc": "docs/subsystems/deliverables.md", "symbol": "WorkspaceChanges", "source": "packages/deliverables/workspace-changes/src/types.ts" + }, + { + "doc": "docs/subsystems/product-telemetry.md", + "symbol": "ProductTelemetryScalar", + "source": "packages/host/product-telemetry-otel/src/index.ts" + }, + { + "doc": "docs/subsystems/product-telemetry.md", + "symbol": "ProductTelemetryRecord", + "source": "packages/host/product-telemetry-otel/src/index.ts" } ] } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 6a4ee18a25..c059754f41 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -49,6 +49,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/experimental/speech-to-text-sensevoice': { kind: 'none', reason: 'Local recognition returns transient text without modifying model context.' }, 'packages/experimental/client-ui-voice-input': { kind: 'none', reason: 'Inserts reviewable text into the unsent draft without submitting to the Agent.' }, 'packages/experimental/voice-input-bundle': { kind: 'none', reason: 'Composes dictation and preparation plugins without adding any model-facing contribution.' }, + 'packages/host/product-telemetry-otel': { kind: 'none', reason: 'The plugin exports explicit analytics records without contributing model context.' }, 'packages/api/terminal-controller': { kind: 'none', reason: 'User-owned terminal processes and screen streams never enter model requests or Session events.' }, 'packages/client/ui-sidebar-terminal': { kind: 'none', reason: 'The browser renders user terminal screens without exposing them to the model.' }, 'packages/ssh/ssh': { kind: 'none', reason: 'The connection owner transports private provider operations; consumers own all model-facing content.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index f386f12aba..d39dff388c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -187,6 +187,7 @@ "@deepseek-ai/dsh-host-directory-picker-native/*": ["./packages/host/directory-picker-native/src/*"], "@deepseek-ai/dsh-host-directory-picker-auto": ["./packages/host/directory-picker-auto/src"], "@deepseek-ai/dsh-host-directory-picker-auto/*": ["./packages/host/directory-picker-auto/src/*"], + "@deepseek-ai/dsh-host-product-telemetry-otel": ["./packages/host/product-telemetry-otel/src"], "@deepseek-ai/dsh-host-open-in-app": ["./packages/host/open-in-app/src"], "@deepseek-ai/dsh-host-open-in-app/shared": ["./packages/host/open-in-app/src/shared.ts"], "@deepseek-ai/dsh-host-webserver": ["./packages/host/webserver/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 50c576de65..de8982ce97 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -177,6 +177,7 @@ { "path": "./packages/experimental/api-speech-to-text" }, { "path": "./packages/experimental/voice-input-bundle" }, + { "path": "./packages/host/product-telemetry-otel" }, { "path": "./packages/client/connection/tsconfig.host.json" }, { "path": "./packages/skill/skill-office" }, { "path": "./packages/skill/tool-workspace-dependencies" },