From c05771d82867f29182c4b01d5daaaff49cb15a6e Mon Sep 17 00:00:00 2001 From: winewill Date: Sun, 20 Sep 2026 14:17:51 +0800 Subject: [PATCH 01/60] build(desktop): isolate test downloads by release ID --- apps/desktop/.env.macos.example | 3 ++ apps/desktop/.env.windows.example | 3 ++ apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 10 ++++- apps/desktop/README.zh.md | 10 ++++- .../desktop-auto-update-environment.d.mts | 9 +++-- .../desktop-auto-update-environment.mjs | 21 +++++++--- .../scripts/desktop-package-environment.mjs | 2 +- apps/desktop/scripts/desktop-upload-plan.ts | 2 +- .../desktop-auto-update-environment.spec.ts | 38 +++++++++++++++++-- .../tests/desktop-package-environment.spec.ts | 21 +++++++++- .../desktop/tests/desktop-upload-plan.spec.ts | 37 +++++++++++++++++- .../test-release-upload-mac-arm64.json | 19 ++++++++++ .../expected/test-release-upload-mac-x64.json | 19 ++++++++++ .../expected/test-release-upload-win-x64.json | 16 ++++++++ .../tests/installed-update-builder.spec.ts | 2 +- .../desktop/tests/installer-packaging.spec.ts | 2 +- apps/desktop/tests/macos-signature.spec.ts | 4 +- apps/desktop/tests/package-macos.spec.ts | 6 +-- .../tests/package-target-stages.spec.ts | 4 +- .../tests/upload-with-credentials.spec.ts | 2 +- .../desktop/tests/windows-asar-unpack.spec.ts | 2 +- apps/desktop/tests/windows-sign.spec.ts | 2 +- 23 files changed, 205 insertions(+), 33 deletions(-) create mode 100644 apps/desktop/tests/expected/test-release-upload-mac-arm64.json create mode 100644 apps/desktop/tests/expected/test-release-upload-mac-x64.json create mode 100644 apps/desktop/tests/expected/test-release-upload-win-x64.json diff --git a/apps/desktop/.env.macos.example b/apps/desktop/.env.macos.example index ef0f625950..e4bfc40676 100644 --- a/apps/desktop/.env.macos.example +++ b/apps/desktop/.env.macos.example @@ -3,6 +3,9 @@ DSH_DESKTOP_APP_ID=com.deepseek.harness DSH_DESKTOP_AUTO_UPDATE_ENV=test DOWNLOAD_TEST_ORIGIN= +# Required for test releases: 32 lowercase hexadecimal characters from a secure random generator. +# Generate once per release batch; keep the same value through packaging and upload retries. +DOWNLOAD_TEST_RELEASE_ID= # Workspace tarball workers; omit to use 4. Must be a positive integer. DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4 diff --git a/apps/desktop/.env.windows.example b/apps/desktop/.env.windows.example index 9b38d46df9..7a8e0dd16b 100644 --- a/apps/desktop/.env.windows.example +++ b/apps/desktop/.env.windows.example @@ -3,6 +3,9 @@ DSH_DESKTOP_APP_ID=com.deepseek.harness DSH_DESKTOP_AUTO_UPDATE_ENV=test DOWNLOAD_TEST_ORIGIN= +# Required for test releases: 32 lowercase hexadecimal characters from a secure random generator. +# Generate once per release batch; keep the same value through packaging and upload retries. +DOWNLOAD_TEST_RELEASE_ID= # The selected mandatory-update origin is required, including unsigned and preparation builds. DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN= diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 455501862e..480b62fd0f 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: 6c9a6a8784732e6fa1369df5632391b789786b22 -README.zh.md: a0f2184d911eeab46902670cf992b6847e2bca31 +README.md: 410c0539caa129ede9a4055cd098da13e5f9371c +README.zh.md: e9367d57088447e580b9ef58c7e35c89d69d8d6e diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 6c9a6a8784..410c0539ca 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -164,7 +164,7 @@ Test and production uploads through `upload:*` retain a fresh `.desktop-build/up Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target -Bucket ` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys. -`DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`; production uses `https://download.deepseek.com`. Upload requires the selected bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. Feed directories are `dsh-desk/feeds//`; versioned packages and blockmaps live in `dsh-desk/bin//`. Targets are `mac-arm64`, `mac-x64`, and `win-x64`. +`DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`; production uses `https://download.deepseek.com`. Upload requires the selected bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. Production feeds use `dsh-desk/feeds//` and binaries use `dsh-desk/bin//`. Test releases require `DOWNLOAD_TEST_RELEASE_ID`: 32 lowercase hexadecimal characters inserted as `dsh-desk//feeds//` and `dsh-desk//bin//`. YAML references, stable-channel aliases, and blockmaps stay inside that release directory. Targets are `mac-arm64`, `mac-x64`, and `win-x64`. The update destination and upload credentials follow the selected deployment: @@ -173,6 +173,14 @@ The update destination and upload credentials follow the selected deployment: | `test` or unset | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`, `DOWNLOAD_TEST_COS_SECRET_KEY` | | `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`, `DOWNLOAD_PROD_COS_SECRET_KEY` | +Generate a fresh ID for each test release batch with the command below and copy its output into `DOWNLOAD_TEST_RELEASE_ID` in `.env.macos` or `.env.windows`. These Git-ignored platform files own the value; shell variables do not override it, and dotenv values are not shell-expanded. Keep the ID through packaging, upload, and retries; upload rejects a completion record with a different update URL. Production does not use this field. + +```sh +node --input-type=module -e 'import { randomBytes } from "node:crypto"; console.log(randomBytes(16).toString("hex"))' +``` + +Distribute each test batch through its complete download links. Installed test clients retain their batch feed and do not discover a new ID automatically. Random paths reduce guessing, not access by link holders; withdrawing a batch requires deleting its COS objects and purging its CDN directory. + Configure the update origin and selected COS bucket, SecretId, and SecretKey in the target dotenv file, then package and upload the same target: ```sh diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index a0f2184d91..e9367d5708 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -164,7 +164,7 @@ test 与 production 的 `upload:*` 上传在发布前置检查通过后,分别 Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target -Bucket ` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。 -`DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包写入的 URL 与后续 COS 上传环境,可取 `test` 或 `production`;缺省为 `test`。测试打包通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin;生产使用 `https://download.deepseek.com`。上传通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选 bucket。清单目录为 `dsh-desk/feeds//`;带版本的安装包和 blockmap 位于 `dsh-desk/bin//`。目标为 `mac-arm64`、`mac-x64` 和 `win-x64`。 +`DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包写入的 URL 与后续 COS 上传环境,可取 `test` 或 `production`;缺省为 `test`。测试打包通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin;生产使用 `https://download.deepseek.com`。上传通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选 bucket。生产清单位于 `dsh-desk/feeds//`,安装包位于 `dsh-desk/bin//`。测试发布必须配置 `DOWNLOAD_TEST_RELEASE_ID`:32 位小写十六进制字符,分别插入路径 `dsh-desk//feeds//` 和 `dsh-desk//bin//`。YAML 引用、稳定通道别名和 blockmap 都位于该发布目录内。目标为 `mac-arm64`、`mac-x64` 和 `win-x64`。 更新目标与上传凭据都与所选环境对应: @@ -173,6 +173,14 @@ Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` | `test` 或未设置 | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`、`DOWNLOAD_TEST_COS_SECRET_KEY` | | `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`、`DOWNLOAD_PROD_COS_SECRET_KEY` | +每个测试发布批次用下方命令生成新 ID,将输出填入 `.env.macos` 或 `.env.windows` 的 `DOWNLOAD_TEST_RELEASE_ID`。这两个被 Git 忽略的平台文件管理该值,shell 变量不能覆盖它,dotenv 值也不会进行 shell 展开。打包、上传和重试必须沿用同一个 ID;上传会拒绝更新 URL 不一致的完成记录。生产环境不使用该字段。 + +```sh +node --input-type=module -e 'import { randomBytes } from "node:crypto"; console.log(randomBytes(16).toString("hex"))' +``` + +通过完整下载链接分发每个测试批次。已安装的测试客户端保留当前批次的清单地址,不会自动发现新 ID。随机路径降低被猜中的概率,不限制持有链接者访问;撤下批次需要删除其 COS 对象并清除对应 CDN 目录缓存。 + 在目标 `.env` 中配置更新地址与所选 COS bucket、SecretId、SecretKey,再打包并上传同一个目标: ```sh diff --git a/apps/desktop/scripts/desktop-auto-update-environment.d.mts b/apps/desktop/scripts/desktop-auto-update-environment.d.mts index b1661646e5..00ebf8c5c0 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.d.mts +++ b/apps/desktop/scripts/desktop-auto-update-environment.d.mts @@ -7,13 +7,14 @@ export type DesktopAutoUpdateEnvironment = 'test' | 'production' /** Directory name of one supported Desktop release target. */ export type DesktopAutoUpdateTarget = 'mac-arm64' | 'mac-x64' | 'win-x64' -/** Public updater URL for one release target. */ +/** Public updater URL and object prefixes for one release target. */ export interface DesktopAutoUpdateConfig { readonly environment: DesktopAutoUpdateEnvironment readonly target: DesktopAutoUpdateTarget readonly origin: string readonly publicUrl: string readonly keyPrefix: string + readonly binaryKeyPrefix: string } /** Public updater URL and private COS destination for one upload target. */ @@ -62,12 +63,12 @@ export function desktopUpdateMetadataFilename( ): string /** - * Resolve the public updater URL for one release target. + * Resolve the public updater URL and object prefixes for one release target. * @param env - Packaging or upload environment. * @param platform - Target Node.js platform. * @param arch - Target Node.js architecture. * @returns Resolved updater configuration. - * @throws When the test deployment lacks a valid HTTPS origin. + * @throws When the test deployment lacks a valid HTTPS origin or a 32-character lowercase hexadecimal release ID. */ export function resolveDesktopAutoUpdateConfig( env: NodeJS.ProcessEnv, @@ -81,7 +82,7 @@ export function resolveDesktopAutoUpdateConfig( * @param platform - Target Node.js platform. * @param arch - Target Node.js architecture. * @returns Resolved upload configuration. - * @throws When the selected deployment lacks a required origin or bucket, or the test origin is not HTTPS. + * @throws When the selected deployment lacks a bucket or valid updater configuration. */ export function resolveDesktopUploadConfig( env: NodeJS.ProcessEnv, diff --git a/apps/desktop/scripts/desktop-auto-update-environment.mjs b/apps/desktop/scripts/desktop-auto-update-environment.mjs index eebd20156a..464d079b4d 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.mjs +++ b/apps/desktop/scripts/desktop-auto-update-environment.mjs @@ -120,12 +120,12 @@ function httpsOrigin(value, name) { } /** - * Resolve the public updater URL for one release target. + * Resolve the public updater URL and object prefixes for one release target. * @param {NodeJS.ProcessEnv} env - Packaging or upload environment. * @param {NodeJS.Platform} platform - Target Node.js platform. * @param {string} arch - Target Node.js architecture. - * @returns {{ environment: 'test' | 'production', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string }} Resolved updater configuration. - * @throws {Error} When the test deployment lacks a valid HTTPS origin. + * @returns {{ environment: 'test' | 'production', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string }} Resolved updater configuration. + * @throws {Error} When the test deployment lacks a valid HTTPS origin or a 32-character lowercase hexadecimal release ID. */ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { const environment = resolveDesktopAutoUpdateEnvironment(env) @@ -137,12 +137,21 @@ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { if (originEnvName === undefined) throw new Error('desktop auto-update: selected deployment has no origin') origin = httpsOrigin(requiredEnvironmentValue(env, originEnvName), originEnvName) } - const keyPrefix = `dsh-desk/feeds/${target}` + let releasePrefix = 'dsh-desk' + if (environment === 'test') { + const releaseId = requiredEnvironmentValue(env, 'DOWNLOAD_TEST_RELEASE_ID') + if (!/^[a-f0-9]{32}$/u.test(releaseId)) { + throw new Error('desktop auto-update: DOWNLOAD_TEST_RELEASE_ID must contain 32 lowercase hexadecimal characters') + } + releasePrefix += `/${releaseId}` + } + const keyPrefix = `${releasePrefix}/feeds/${target}` return { environment, target, origin, keyPrefix, + binaryKeyPrefix: `${releasePrefix}/bin/${target}`, publicUrl: `${origin}/${keyPrefix}/`, } } @@ -152,8 +161,8 @@ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { * @param {NodeJS.ProcessEnv} env - Upload environment. * @param {NodeJS.Platform} platform - Target Node.js platform. * @param {string} arch - Target Node.js architecture. - * @returns {{ environment: 'test' | 'production', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, bucket: string, secretIdEnvName: string, secretKeyEnvName: string }} Resolved upload configuration. - * @throws {Error} When the selected deployment lacks a required origin or bucket, or the test origin is not HTTPS. + * @returns {{ environment: 'test' | 'production', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string, bucket: string, secretIdEnvName: string, secretKeyEnvName: string }} Resolved upload configuration. + * @throws {Error} When the selected deployment lacks a bucket or valid updater configuration. */ export function resolveDesktopUploadConfig(env, platform, arch) { const update = resolveDesktopAutoUpdateConfig(env, platform, arch) diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index c8a794e3c7..8fe35b60bf 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -12,7 +12,7 @@ import { resolveMacOSPackageSettings } from './macos-package-settings.mjs' import { resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs' const APP_ROOT = fileURLToPath(new URL('..', import.meta.url)) -const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u +const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR)$/u const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu diff --git a/apps/desktop/scripts/desktop-upload-plan.ts b/apps/desktop/scripts/desktop-upload-plan.ts index 0ec76dba3a..32f891dcc8 100644 --- a/apps/desktop/scripts/desktop-upload-plan.ts +++ b/apps/desktop/scripts/desktop-upload-plan.ts @@ -222,7 +222,7 @@ export async function createDesktopUploadPlan( const updaterInfo = updateFileInfo(metadata.files[0], `${metadataFilename}.files[0]`, `${base}.${updaterExtension}`) const updaterPath = await verifyChecksummedArtifact(artifactsRoot, updaterInfo) const artifacts: DesktopUploadArtifact[] = [] - const binaryPrefix = `dsh-desk/bin/${targetName}` + const binaryPrefix = update.binaryKeyPrefix if (target.platform === 'darwin') { const dmgPath = await requireArtifact(artifactsRoot, `${base}.dmg`) diff --git a/apps/desktop/tests/desktop-auto-update-environment.spec.ts b/apps/desktop/tests/desktop-auto-update-environment.spec.ts index 747657042b..c33a9384df 100644 --- a/apps/desktop/tests/desktop-auto-update-environment.spec.ts +++ b/apps/desktop/tests/desktop-auto-update-environment.spec.ts @@ -8,20 +8,48 @@ import { resolveDesktopUploadConfig, } from '../scripts/desktop-auto-update-environment.mjs' +const RELEASE_ID = '0123456789abcdef0123456789abcdef' + describe('desktop auto-update environment', () => { + it.each([undefined, 'test'])('requires a release ID for deployment %s', (deployment) => { + const environment = { DSH_DESKTOP_AUTO_UPDATE_ENV: deployment, DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', + DOWNLOAD_TEST_COS_BUCKET: 'test-bucket' } + expect(() => resolveDesktopAutoUpdateConfig(environment, 'win32', 'x64')).toThrow(/DOWNLOAD_TEST_RELEASE_ID/u) + expect(() => resolveDesktopUploadConfig(environment, 'win32', 'x64')).toThrow(/DOWNLOAD_TEST_RELEASE_ID/u) + }) + + it.each(['', ' ', 'release-1', 'a'.repeat(31), 'a'.repeat(33), 'A'.repeat(32), 'g'.repeat(32), + '../feeds', '01234567-89ab-cdef-0123-456789abcdef', `${RELEASE_ID}/bin`])('rejects invalid test release ID %j', (id) => { + const environment = { DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', DOWNLOAD_TEST_RELEASE_ID: id } + expect(() => resolveDesktopAutoUpdateConfig(environment, 'darwin', 'arm64')).toThrow(/DOWNLOAD_TEST_RELEASE_ID/u) + }) + + it.each([['darwin', 'arm64', 'mac-arm64'], ['darwin', 'x64', 'mac-x64'], ['win32', 'x64', 'win-x64']] as const) + ('uses one test release directory for %s %s feeds and binaries', (platform, arch, target) => { + expect(resolveDesktopAutoUpdateConfig({ DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', + DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID }, platform, arch)).toMatchObject({ + publicUrl: `https://updates.example.com/dsh-desk/${RELEASE_ID}/feeds/${target}/`, + keyPrefix: `dsh-desk/${RELEASE_ID}/feeds/${target}`, + binaryKeyPrefix: `dsh-desk/${RELEASE_ID}/bin/${target}`, + }) + }) + it('defaults packages and uploads to the test deployment', () => { expect(resolveDesktopAutoUpdateEnvironment({})).toBe('test') expect(resolveDesktopAutoUpdateConfig({ DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com/', + DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID, }, 'darwin', 'arm64')).toEqual({ environment: 'test', target: 'mac-arm64', origin: 'https://desktop-updates.example.com', - publicUrl: 'https://desktop-updates.example.com/dsh-desk/feeds/mac-arm64/', - keyPrefix: 'dsh-desk/feeds/mac-arm64', + publicUrl: `https://desktop-updates.example.com/dsh-desk/${RELEASE_ID}/feeds/mac-arm64/`, + keyPrefix: `dsh-desk/${RELEASE_ID}/feeds/mac-arm64`, + binaryKeyPrefix: `dsh-desk/${RELEASE_ID}/bin/mac-arm64`, }) expect(resolveDesktopUploadConfig({ DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com/', + DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID, DOWNLOAD_TEST_COS_BUCKET: 'test-download-bucket', }, 'darwin', 'arm64')).toMatchObject({ bucket: 'test-download-bucket', @@ -30,13 +58,15 @@ describe('desktop auto-update environment', () => { }) }) - it('selects the production URL for packages and bucket for uploads', () => { + it.each([undefined, RELEASE_ID, 'unused-test-value'])('keeps production paths independent of test release ID %s', (id) => { expect(resolveDesktopAutoUpdateConfig({ DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', + DOWNLOAD_TEST_RELEASE_ID: id, }, 'win32', 'x64')).toMatchObject({ environment: 'production', target: 'win-x64', publicUrl: 'https://download.deepseek.com/dsh-desk/feeds/win-x64/', + binaryKeyPrefix: 'dsh-desk/bin/win-x64', }) expect(resolveDesktopUploadConfig({ DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', @@ -53,9 +83,11 @@ describe('desktop auto-update environment', () => { .toThrow(/DOWNLOAD_TEST_ORIGIN/u) expect(resolveDesktopAutoUpdateConfig({ DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', + DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID, }, 'darwin', 'arm64').publicUrl).toContain('/mac-arm64/') expect(() => resolveDesktopUploadConfig({ DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', + DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID, }, 'darwin', 'arm64')).toThrow(/DOWNLOAD_TEST_COS_BUCKET/u) expect(() => resolveDesktopUploadConfig({ DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', diff --git a/apps/desktop/tests/desktop-package-environment.spec.ts b/apps/desktop/tests/desktop-package-environment.spec.ts index 7153f135a3..98d93e0c07 100644 --- a/apps/desktop/tests/desktop-package-environment.spec.ts +++ b/apps/desktop/tests/desktop-package-environment.spec.ts @@ -8,7 +8,8 @@ const WINDOWS = { platform: 'win32', arch: 'x64' } as const const MACOS = { platform: 'darwin', arch: 'arm64' } as const const POLICY = { DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }) } -const RELEASE = { ...POLICY, DSH_DESKTOP_APP_ID: 'com.example.desktop', DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com' } +const RELEASE = { ...POLICY, DSH_DESKTOP_APP_ID: 'com.example.desktop', DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', + DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef' } const MAC_IDENTITY = { DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)', DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234' } async function withDirectory(action: (directory: string) => Promise): Promise { @@ -33,6 +34,7 @@ describe('Desktop local packaging configuration', () => { dsh_desktop_mandatory_update_prod_origin: 'https://stale.example.com', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'stale-pin', APPLE_ID: 'stale-apple-id', CSC_LINK: 'stale-certificate', DOWNLOAD_TEST_ORIGIN: 'https://stale.example.com', + DOWNLOAD_TEST_RELEASE_ID: 'a'.repeat(32), download_test_release_id: 'b'.repeat(32), dsh_desktop_windows_key_container: 'case-insensitive-stale-container', } expect(loadDesktopPackageEnvironment('win32', parent, directory)).toEqual({ @@ -49,6 +51,23 @@ describe('Desktop local packaging configuration', () => { }) }) + it.each(['win32', 'darwin'] as const)('owns the %s release ID in its platform file', async (platform) => { + await withDirectory(async (directory) => { + const settings = Object.entries(RELEASE).map(([name, value]) => `${name}='${value}'`).join('\n') + '\n' + const file = join(directory, platform === 'win32' ? '.env.windows' : '.env.macos') + const parent = { DOWNLOAD_TEST_RELEASE_ID: 'a'.repeat(32) } + await writeFile(file, settings) + expect(loadDesktopPackageEnvironment(platform, parent, directory).DOWNLOAD_TEST_RELEASE_ID).toBe(RELEASE.DOWNLOAD_TEST_RELEASE_ID) + await writeFile(file, settings.replace(`DOWNLOAD_TEST_RELEASE_ID='${RELEASE.DOWNLOAD_TEST_RELEASE_ID}'\n`, '')) + const missing = loadDesktopPackageEnvironment(platform, parent, directory) + expect(missing.DOWNLOAD_TEST_RELEASE_ID).toBeUndefined() + expect(() => { + validateDesktopPackageEnvironment(missing, platform === 'win32' ? WINDOWS : MACOS) + }).toThrow(/DOWNLOAD_TEST_RELEASE_ID/u) + expect(parent.DOWNLOAD_TEST_RELEASE_ID).toBe('a'.repeat(32)) + }) + }) + it('loads mandatory update origins and options only from the platform file without changing its parent', async () => { await withDirectory(async (directory) => { const windowsPolicy = JSON.stringify({ intervalMs: 5000, allowedPageOrigins: ['https://download.example.invalid'] }) diff --git a/apps/desktop/tests/desktop-upload-plan.spec.ts b/apps/desktop/tests/desktop-upload-plan.spec.ts index d65a44a923..bf4d68843c 100644 --- a/apps/desktop/tests/desktop-upload-plan.spec.ts +++ b/apps/desktop/tests/desktop-upload-plan.spec.ts @@ -12,6 +12,7 @@ import type { DesktopPackageTargetName } from '../scripts/package-target.ts' const temporaryDirectories: string[] = [] const TEST_ORIGIN = 'https://desktop-updates.example.com' const TEST_BUCKET = 'test-download-bucket' +const RELEASE_ID = '0123456789abcdef0123456789abcdef' const PRODUCTION_BUCKET = 'production-download-bucket' const require = createRequire(import.meta.url) const { createBlockmap } = require('app-builder-lib/out/targets/differentialUpdateInfoBuilder.js') as { @@ -54,7 +55,7 @@ async function fixture( target, version, environment, - publicUrl: `${origin}/dsh-desk/feeds/${target}/`, + publicUrl: `${origin}/dsh-desk/${environment === 'test' ? `${RELEASE_ID}/` : ''}feeds/${target}/`, })}\n`) if (os === 'mac') { @@ -64,6 +65,7 @@ async function fixture( await writeFile(join(artifactsRoot, `${base}.dmg`), 'notarized DMG fixture') await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'darwin')), `${JSON.stringify({ version, + path: `${base}.zip`, files: [{ url: `${base}.zip`, size: Buffer.byteLength(zip), sha512: digest(zip) }], })}\n`) } @@ -75,6 +77,7 @@ async function fixture( expect(Object.hasOwn(info, 'blockMapSize')).toBe(false) await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'win32')), `${JSON.stringify({ version, + path: `${base}.exe`, files: [{ url: `${base}.exe`, ...info, @@ -89,6 +92,7 @@ async function fixture( ? { DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: TEST_ORIGIN, + DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID, DOWNLOAD_TEST_COS_BUCKET: TEST_BUCKET, } : { @@ -132,7 +136,7 @@ describe('desktop upload plan', () => { expect(plan).toMatchObject({ environment: 'test', version: '1.2.3', - publicUrl: 'https://desktop-updates.example.com/dsh-desk/feeds/mac-arm64/', + publicUrl: `https://desktop-updates.example.com/dsh-desk/${RELEASE_ID}/feeds/mac-arm64/`, bucket: TEST_BUCKET, }) expect(plan.artifacts.map(artifact => artifact.filename)).toEqual([ @@ -147,6 +151,34 @@ describe('desktop upload plan', () => { }) }) + it.each(['mac-arm64', 'mac-x64', 'win-x64'] as const)('publishes every %s object and YAML reference inside the test release directory', async (target) => { + const paths = await fixture(target) + const plan = await createDesktopUploadPlan(target, paths) + const prefix = `dsh-desk/${RELEASE_ID}` + const payload = plan.artifacts.find(artifact => artifact.filename.endsWith(target === 'win-x64' ? '.exe' : '.zip'))! + for (const artifact of plan.artifacts) { + expect(artifact.key).toBe(`${prefix}/${artifact.channelMetadata ? 'feeds' : 'bin'}/${target}/${artifact.filename}`) + if (artifact.channelMetadata) { + expect(load(artifact.contents!)).toMatchObject({ + path: `${TEST_ORIGIN}/${payload.key}`, + files: [{ url: `${TEST_ORIGIN}/${payload.key}` }], + }) + } + } + await expect(JSON.stringify(plan.artifacts.map(({ key, contents }) => ({ key, contents })), null, 2) + '\n') + .toMatchFileSnapshot(`./expected/test-release-upload-${target}.json`) + }) + + it('rejects a changed or missing release ID before uploading a completed package', async () => { + const paths = await fixture('mac-arm64') + await expect(createDesktopUploadPlan('mac-arm64', { + ...paths, environment: { ...paths.environment, DOWNLOAD_TEST_RELEASE_ID: 'a'.repeat(32) }, + })).rejects.toThrow(/completion record/u) + await expect(createDesktopUploadPlan('mac-arm64', { + ...paths, environment: { ...paths.environment, DOWNLOAD_TEST_RELEASE_ID: undefined }, + })).rejects.toThrow(/DOWNLOAD_TEST_RELEASE_ID/u) + }) + it('uploads the prerelease channel metadata emitted by electron-builder', async () => { const paths = await fixture('mac-arm64', '1.2.3-alpha.4') const plan = await createDesktopUploadPlan('mac-arm64', paths) @@ -193,6 +225,7 @@ describe('desktop upload plan', () => { environment: { DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: TEST_ORIGIN, + DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID, DOWNLOAD_TEST_COS_BUCKET: TEST_BUCKET, }, })).rejects.toThrow(/completion record.*test/u) diff --git a/apps/desktop/tests/expected/test-release-upload-mac-arm64.json b/apps/desktop/tests/expected/test-release-upload-mac-arm64.json new file mode 100644 index 0000000000..515f0234f9 --- /dev/null +++ b/apps/desktop/tests/expected/test-release-upload-mac-arm64.json @@ -0,0 +1,19 @@ +[ + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.dmg" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip.blockmap" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-arm64/nightly-mac.yml", + "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip\n size: 24\n sha512: >-\n IgtiHRPFjickTxiSTW6k0ltyBNOF/gpMGj/xflzVV2iRIzUSFPY6BG45u9fR4GmAJSVtN+FMh3NZx7mddUfJLw==\n" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-arm64/latest-mac.yml", + "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip\n size: 24\n sha512: >-\n IgtiHRPFjickTxiSTW6k0ltyBNOF/gpMGj/xflzVV2iRIzUSFPY6BG45u9fR4GmAJSVtN+FMh3NZx7mddUfJLw==\n" + } +] diff --git a/apps/desktop/tests/expected/test-release-upload-mac-x64.json b/apps/desktop/tests/expected/test-release-upload-mac-x64.json new file mode 100644 index 0000000000..5039a3aede --- /dev/null +++ b/apps/desktop/tests/expected/test-release-upload-mac-x64.json @@ -0,0 +1,19 @@ +[ + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.dmg" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip.blockmap" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-x64/nightly-mac.yml", + "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip\n size: 24\n sha512: >-\n IgtiHRPFjickTxiSTW6k0ltyBNOF/gpMGj/xflzVV2iRIzUSFPY6BG45u9fR4GmAJSVtN+FMh3NZx7mddUfJLw==\n" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-x64/latest-mac.yml", + "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip\n size: 24\n sha512: >-\n IgtiHRPFjickTxiSTW6k0ltyBNOF/gpMGj/xflzVV2iRIzUSFPY6BG45u9fR4GmAJSVtN+FMh3NZx7mddUfJLw==\n" + } +] diff --git a/apps/desktop/tests/expected/test-release-upload-win-x64.json b/apps/desktop/tests/expected/test-release-upload-win-x64.json new file mode 100644 index 0000000000..cb60e0ce29 --- /dev/null +++ b/apps/desktop/tests/expected/test-release-upload-win-x64.json @@ -0,0 +1,16 @@ +[ + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe.blockmap" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/win-x64/nightly.yml", + "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe\n size: 30\n sha512: >-\n eVumVOW1yvT3fAgGKUnliEzSfhmn8jUfzpNaUPufU1yekWrwJe8wRPC+JOrHH2Hmc/eLynwxjX1yIiER8tVOTA==\n" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/win-x64/latest.yml", + "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe\n size: 30\n sha512: >-\n eVumVOW1yvT3fAgGKUnliEzSfhmn8jUfzpNaUPufU1yekWrwJe8wRPC+JOrHH2Hmc/eLynwxjX1yIiER8tVOTA==\n" + } +] diff --git a/apps/desktop/tests/installed-update-builder.spec.ts b/apps/desktop/tests/installed-update-builder.spec.ts index 55ad22fd5d..089b04deef 100644 --- a/apps/desktop/tests/installed-update-builder.spec.ts +++ b/apps/desktop/tests/installed-update-builder.spec.ts @@ -18,7 +18,7 @@ vi.mock('../scripts/windows-sign.mjs', () => ({ })) const versions = ['0.1.6-nightly.20260914.1', '0.1.6-nightly.20260914.2'] as const -const environment = { DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: 'https://download-test.deepseek.com', +const environment = { DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: 'https://download-test.deepseek.com', DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef', DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }), DOWNLOAD_TEST_COS_BUCKET: 'bj-toc-download-test-1320056602' } diff --git a/apps/desktop/tests/installer-packaging.spec.ts b/apps/desktop/tests/installer-packaging.spec.ts index 1dcfcb24fd..75d6b02d7e 100644 --- a/apps/desktop/tests/installer-packaging.spec.ts +++ b/apps/desktop/tests/installer-packaging.spec.ts @@ -31,7 +31,7 @@ describe('installer preparation preserves application dependencies', () => { DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)', DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234', APPLE_KEYCHAIN_PROFILE: 'installer-test', - DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', + DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef', } for (const [name, value] of Object.entries(env)) vi.stubEnv(name, value) try { diff --git a/apps/desktop/tests/macos-signature.spec.ts b/apps/desktop/tests/macos-signature.spec.ts index 2bd63adbaa..4d71d3048b 100644 --- a/apps/desktop/tests/macos-signature.spec.ts +++ b/apps/desktop/tests/macos-signature.spec.ts @@ -22,7 +22,7 @@ const RELEASE_ENVIRONMENT = { APPLE_API_KEY: '/private/credentials/AuthKey_TEST123456.p8', APPLE_API_KEY_ID: 'TEST123456', APPLE_API_ISSUER: '11111111-2222-3333-4444-555555555555', - DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', + DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef', } function portablePath(value: string): string { @@ -71,7 +71,7 @@ describe('desktop macOS release signature', () => { }, publish: [{ provider: 'generic', - url: 'https://desktop-updates.example.com/dsh-desk/feeds/mac-arm64/', + url: 'https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-arm64/', channel: 'nightly', }], }) diff --git a/apps/desktop/tests/package-macos.spec.ts b/apps/desktop/tests/package-macos.spec.ts index 3b780b8b06..e8fb5dcf34 100644 --- a/apps/desktop/tests/package-macos.spec.ts +++ b/apps/desktop/tests/package-macos.spec.ts @@ -17,7 +17,7 @@ const environment = { DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)', DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234', APPLE_KEYCHAIN_PROFILE: 'fixture-profile', - DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', + DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef', } function barrier() { @@ -33,7 +33,7 @@ async function fixture(arch: 'arm64' | 'x64' = 'arm64') { await mkdir(join(appPath, 'Contents', 'Resources'), { recursive: true }) await writeFile(join(appPath, 'payload'), 'signed content') await writeMacOSAppUpdateConfig(join(appPath, 'Contents', 'Resources'), { - publicUrl: `https://desktop-updates.example.com/dsh-desk/feeds/mac-${arch}/`, + publicUrl: `https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-${arch}/`, }, 'deepseek-harness-updater') const version = '1.2.3-alpha.1' const base = `deepseek-harness-${version}-mac-${arch}` @@ -107,7 +107,7 @@ describe('parallel macOS artifacts', () => { .toEqual({ payload: 'signed content', appTicket: false }) expect(await readFile(join(f.appPath, 'ticket'), 'utf8')).toBe('accepted') expect(await readFile(join(f.appPath, 'Contents', 'Resources', 'app-update.yml'), 'utf8')) - .toContain(`/dsh-desk/feeds/mac-${arch}/`) + .toContain(`/dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-${arch}/`) expect((await readdir(f.root)).sort()).toEqual(['artifacts']) expect(f.apple.verifySignature).toHaveBeenCalledTimes(4) expect(f.apple.verifyNotarization).toHaveBeenCalledTimes(1) diff --git a/apps/desktop/tests/package-target-stages.spec.ts b/apps/desktop/tests/package-target-stages.spec.ts index 5b8880ce6a..9e8f98b4c8 100644 --- a/apps/desktop/tests/package-target-stages.spec.ts +++ b/apps/desktop/tests/package-target-stages.spec.ts @@ -29,7 +29,7 @@ vi.mock('node:fs', async importOriginal => ({ afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks() }) const environment = { DSH_DESKTOP_APP_ID: 'com.example.test', DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', - DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin' } + DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin' } function supervisor(failure?: string) { vi.stubEnv('npm_execpath', 'fixture-pnpm.cjs') @@ -59,6 +59,8 @@ it('requires one signing preflight before building, then records only the comple } } expect(writeFileSync).toHaveBeenCalledOnce() + const record = JSON.parse(vi.mocked(writeFileSync).mock.calls[0]![1] as string) as { publicUrl: string } + expect(record.publicUrl).toBe('https://updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/feeds/win-x64/') }) it('initializes shared storage only after acquiring the preflight stage lock', async () => { diff --git a/apps/desktop/tests/upload-with-credentials.spec.ts b/apps/desktop/tests/upload-with-credentials.spec.ts index 9a6a93f0a8..29688621ca 100644 --- a/apps/desktop/tests/upload-with-credentials.spec.ts +++ b/apps/desktop/tests/upload-with-credentials.spec.ts @@ -86,7 +86,7 @@ afterEach(async () => { describe('credential launcher destination', () => { const fileEnvironment = { - DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: 'https://download-test.example.com', + DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: 'https://download-test.example.com', DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef', DOWNLOAD_TEST_COS_BUCKET: 'test-bucket', DOWNLOAD_TEST_COS_SECRET_ID: 'stale-id', DOWNLOAD_TEST_COS_SECRET_KEY: 'stale-key', } const injectedEnvironment = { diff --git a/apps/desktop/tests/windows-asar-unpack.spec.ts b/apps/desktop/tests/windows-asar-unpack.spec.ts index ddfaf15de2..2d5c79563b 100644 --- a/apps/desktop/tests/windows-asar-unpack.spec.ts +++ b/apps/desktop/tests/windows-asar-unpack.spec.ts @@ -168,7 +168,7 @@ it.each([true, false])('validates the real builder hook for unsigned=%s', async DSH_DESKTOP_APP_ID: 'com.example.unpack', DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }), DSH_DESKTOP_TARGET_PLATFORM: 'win32', DSH_DESKTOP_TARGET_ARCH: 'x64', DSH_DESKTOP_UNSIGNED: unsigned ? '1' : '0', - DSH_DESKTOP_WINDOWS_CER_FILE: certificate, DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', + DSH_DESKTOP_WINDOWS_CER_FILE: certificate, DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef', }, 'win32', 'x64', input.source) // Qualification replaces files after creating the base configuration; builder owns a separate config object. input.config.asarUnpack = [...config.asarUnpack] diff --git a/apps/desktop/tests/windows-sign.spec.ts b/apps/desktop/tests/windows-sign.spec.ts index 1b1440b0fe..e5d5a7cd3f 100644 --- a/apps/desktop/tests/windows-sign.spec.ts +++ b/apps/desktop/tests/windows-sign.spec.ts @@ -159,7 +159,7 @@ describe('Windows token signing', () => { await writeFile(signTool, 'fixture') await writeFile(path, 'fixture') validateDesktopPackageEnvironment({ - DSH_DESKTOP_APP_ID: 'com.example.desktop', DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', + DSH_DESKTOP_APP_ID: 'com.example.desktop', DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef', DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }), DSH_DESKTOP_WINDOWS_CER_FILE: certificateFile, DSH_DESKTOP_WINDOWS_SIGNTOOL: signTool, From 8e5073ee617532d0e526118d94aa13cbbf4b8d15 Mon Sep 17 00:00:00 2001 From: winewill Date: Sun, 20 Sep 2026 14:44:36 +0800 Subject: [PATCH 02/60] docs(desktop): clarify test release ID reuse and qualification --- ...08-25-electron-desktop-packaging-and-updates.i18n.yaml | 4 ++-- .../2026-08-25-electron-desktop-packaging-and-updates.md | 2 ++ ...026-08-25-electron-desktop-packaging-and-updates.zh.md | 2 ++ apps/desktop/.env.macos.example | 2 +- apps/desktop/.env.windows.example | 2 +- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 8 +++++--- apps/desktop/README.zh.md | 8 +++++--- apps/desktop/tests/installed-update/README.i18n.yaml | 4 ++-- apps/desktop/tests/installed-update/README.md | 2 +- apps/desktop/tests/installed-update/README.zh.md | 2 +- 11 files changed, 24 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml index 6fa3c9e31b..6a9b597434 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.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 .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md -2026-08-25-electron-desktop-packaging-and-updates.md: f58e8dc471faeb717dd79d32df4afc1b5cc3458f -2026-08-25-electron-desktop-packaging-and-updates.zh.md: 2d2d5c68f28974b606693d5b6727c864c92a0763 +2026-08-25-electron-desktop-packaging-and-updates.md: a985e1c91a1ccdb9c4aab0bc519974f9b6fafd4a +2026-08-25-electron-desktop-packaging-and-updates.zh.md: 17ace865477d4fae3650b56d6725be07c7bd0e0f diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md index f58e8dc471..a985e1c91a 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md @@ -75,6 +75,8 @@ The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-s `DSH_DESKTOP_AUTO_UPDATE_ENV` selects the test deployment by default or the production deployment for both the target-specific generic-provider URL and COS destination. Release automation supplies the test HTTPS origin through `DOWNLOAD_TEST_ORIGIN` and each deployment's bucket through `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`; keeping mutable test routing and COS storage identities out of source lets deployment infrastructure change without a code release, while the public production origin remains fixed. Packaging resolves only the public updater URL, disables electron-builder publishing, removes every COS credential field from its subprocess environment, and writes a completion record only after electron-builder and every signing or notarization hook succeeds. Target upload additionally requires the selected bucket, then requires the completion record, root dsh version, Desktop version, version-derived channel metadata, artifact names, sizes, and SHA-512 values to agree before it reads the selected credentials or sends data. It uploads immutable versioned updater payloads and any separate blockmaps before replacing the channel metadata emitted by electron-builder, and it never deletes historical objects. Stable versions use the `latest` metadata name; prereleases use the first semantic-version prerelease identifier. NSIS embeds its blockmap in the signed executable; the macOS ZIP carries a separate blockmap. Both let electron-updater download changed blocks when supported, while application replacement and the local pnpm package operation remain separate operations. +Test releases require `DOWNLOAD_TEST_RELEASE_ID` from the platform dotenv file. Their feeds and binaries share `dsh-desk//`; production retains its fixed directories. The ID is part of the packaged update URL, so upload rejects a different ID. Internal distribution can supply a new installer after rotation; installed clients retain their existing feed. Random directories reduce guessing but provide no authorization for link holders. The [release configuration](../../../../apps/desktop/README.md#upload-updates) defines generation and reuse. + ## Security and release policy Core dsh and the private Desktop Host come only from the signed application resource tree. Plugin installation forwards package specs to pnpm, including local and remote sources, but never accepts raw pnpm commands. pnpm owns dependency resolution and the profile’s `allowBuilds` policy; the Host loads activated bundles. diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md index 2d2d5c68f2..17ace86547 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md @@ -75,6 +75,8 @@ Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-b `DSH_DESKTOP_AUTO_UPDATE_ENV` 默认为测试部署,也可以选择生产部署,并同时决定目标专用的 generic-provider URL 与 COS 目标。发布自动化通过 `DOWNLOAD_TEST_ORIGIN` 提供测试 HTTPS origin,并通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供各部署的 bucket;可变的测试路由与 COS 存储身份不写入源码,部署基础设施变更时无需发布新代码,而公开的生产 origin 仍固定。打包只解析公开更新 URL、禁止 electron-builder 发布、从子进程环境中删除每个 COS 凭据字段,并且只有在 electron-builder 以及每个签名或公证 hook 成功后才写入完成记录。目标上传还必须提供所选 bucket,随后会先要求完成记录、根 dsh 版本、Desktop 版本、根据版本得出的频道元数据、产物名称、大小与 SHA-512 全部一致,再读取所选凭据或发送数据。它先上传不可变且带版本的更新载荷与所有独立 blockmap,最后替换 electron-builder 生成的频道元数据,并且不会删除历史对象。稳定版本使用 `latest` 元数据名称,预发布版本则使用语义化版本的第一个预发布标识符。NSIS 把 blockmap 嵌入已签名的可执行文件,macOS ZIP 则使用独立 blockmap;两者都让 electron-updater 在平台支持时只下载变化的数据块,而应用替换与本地 pnpm 包操作仍是两个独立操作。 +测试发布必须从平台 dotenv 文件提供 `DOWNLOAD_TEST_RELEASE_ID`。清单和安装包共用 `dsh-desk//`,生产环境保留固定目录。ID 是打包更新 URL 的一部分,因此上传会拒绝不同的 ID。轮换后可通过内部分发提供新安装包,已安装客户端保留原清单地址。随机目录降低被猜中的概率,但不对持有链接者进行鉴权。[发布配置](../../../../apps/desktop/README.zh.md#upload-updates)定义生成与复用方式。 + ## 安全与发布策略 核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装把包规格交给 pnpm,包括本地和远程来源,但不接受原始 pnpm 命令。pnpm 负责依赖解析和 profile 的 `allowBuilds` 策略;Host 加载已启用的 bundle。 diff --git a/apps/desktop/.env.macos.example b/apps/desktop/.env.macos.example index e4bfc40676..960e1195c5 100644 --- a/apps/desktop/.env.macos.example +++ b/apps/desktop/.env.macos.example @@ -4,7 +4,7 @@ DSH_DESKTOP_APP_ID=com.deepseek.harness DSH_DESKTOP_AUTO_UPDATE_ENV=test DOWNLOAD_TEST_ORIGIN= # Required for test releases: 32 lowercase hexadecimal characters from a secure random generator. -# Generate once per release batch; keep the same value through packaging and upload retries. +# Generate once per release batch; retain it for retries and versions testing upgrades within that batch. DOWNLOAD_TEST_RELEASE_ID= # Workspace tarball workers; omit to use 4. Must be a positive integer. diff --git a/apps/desktop/.env.windows.example b/apps/desktop/.env.windows.example index 7a8e0dd16b..b1001c6477 100644 --- a/apps/desktop/.env.windows.example +++ b/apps/desktop/.env.windows.example @@ -4,7 +4,7 @@ DSH_DESKTOP_APP_ID=com.deepseek.harness DSH_DESKTOP_AUTO_UPDATE_ENV=test DOWNLOAD_TEST_ORIGIN= # Required for test releases: 32 lowercase hexadecimal characters from a secure random generator. -# Generate once per release batch; keep the same value through packaging and upload retries. +# Generate once per release batch; retain it for retries and versions testing upgrades within that batch. DOWNLOAD_TEST_RELEASE_ID= # The selected mandatory-update origin is required, including unsigned and preparation builds. diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 480b62fd0f..b7937d5003 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: 410c0539caa129ede9a4055cd098da13e5f9371c -README.zh.md: e9367d57088447e580b9ef58c7e35c89d69d8d6e +README.md: cd559119d80efbc66988063a947878d43775d5c8 +README.zh.md: 48fc783501f1d44a7a0d62152be5baaaa83357e7 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 410c0539ca..cd559119d8 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -158,6 +158,8 @@ pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis The Windows installer extracts the new version beside the installation directory, stops the old application, and replaces directories through same-volume renames. Same-path upgrades preserve the old directory until promotion succeeds; extraction failure leaves it intact, and promotion failure attempts to restore it. The installer removes the old backup before launch. Forced termination or power loss can leave `.new-*` or `.old-*` directories; installation-location and scope migrations retain electron-builder's old-uninstaller flow. + + ### Upload updates Test and production uploads through `upload:*` retain a fresh `.desktop-build/upload-records/--*` directory after release preflight. `plan.json` records destination, version, every file's size/SHA-512, and published YAML bytes; flushed `events.jsonl` records PUT intent and available response status/request ID; `result.json` records completion or the last failed stage. A missing final result means interruption or unavailable storage, not success. No credential values, authorization headers, or raw SDK errors are recorded. Audit-write failures stop later PUTs. Every object is one streamed Tencent COS PUT with an explicit length and Content-MD5; the COS SDK repeats a request only when its body is not a stream, and this uploader does not retry either. Retain partial records and inspect remote state before another operation: a timeout or failed receipt write does not prove that the object was not stored. These records are local, not tamper-proof or automatically backed up; archive each release's records with its build evidence in controlled storage. Public CDN readback remains separate release qualification, explicitly marked `not-performed` in the upload result. @@ -173,13 +175,13 @@ The update destination and upload credentials follow the selected deployment: | `test` or unset | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`, `DOWNLOAD_TEST_COS_SECRET_KEY` | | `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`, `DOWNLOAD_PROD_COS_SECRET_KEY` | -Generate a fresh ID for each test release batch with the command below and copy its output into `DOWNLOAD_TEST_RELEASE_ID` in `.env.macos` or `.env.windows`. These Git-ignored platform files own the value; shell variables do not override it, and dotenv values are not shell-expanded. Keep the ID through packaging, upload, and retries; upload rejects a completion record with a different update URL. Production does not use this field. +Generate a fresh ID for each test release batch with the command below and copy its output into `DOWNLOAD_TEST_RELEASE_ID` in `.env.macos` or `.env.windows`. These Git-ignored platform files own the value; shell variables do not override it, and dotenv values are not shell-expanded. Keep the ID through packaging, upload, and retries; upload rejects a completion record with a different update URL. To test an upgrade between versions, reuse the installed client’s ID for the later version. Production does not use this field. ```sh -node --input-type=module -e 'import { randomBytes } from "node:crypto"; console.log(randomBytes(16).toString("hex"))' +node --input-type=module -e "import { randomBytes } from 'node:crypto'; console.log(randomBytes(16).toString('hex'))" ``` -Distribute each test batch through its complete download links. Installed test clients retain their batch feed and do not discover a new ID automatically. Random paths reduce guessing, not access by link holders; withdrawing a batch requires deleting its COS objects and purging its CDN directory. +Distribute each test batch through its complete download links. Installed test clients retain their batch feed and do not discover a new ID automatically. Format validation cannot establish randomness; use the generator output. Random paths reduce guessing, not access by link holders; withdrawing a batch requires deleting its COS objects and purging its CDN directory. Configure the update origin and selected COS bucket, SecretId, and SecretKey in the target dotenv file, then package and upload the same target: diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index e9367d5708..48fc783501 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -158,6 +158,8 @@ pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis Windows 安装器先将新版本解压到安装目录旁边,再退出旧应用并通过同卷目录改名完成替换。同路径升级在替换成功前保留旧目录;解压失败时旧版不变,替换失败时尝试恢复旧目录。安装器在启动前清理旧版备份。强制结束安装器或断电可能留下 `.new-*` 或 `.old-*` 目录;不同安装位置或安装范围迁移仍使用 electron-builder 的旧卸载器流程。 + + ### 上传更新 test 与 production 的 `upload:*` 上传在发布前置检查通过后,分别保留新的 `.desktop-build/upload-records/--*` 目录。`plan.json` 记录目标、版本、每个文件的大小/SHA-512 和发布的 YAML 字节;刷盘的 `events.jsonl` 记录 PUT 意图及可用的响应状态/请求 ID;`result.json` 记录完成结果或最后失败阶段。缺少最终结果表示中断或存储不可用,不表示成功。不记录凭据值、认证头或原始 SDK 错误。审计写入失败即停止后续 PUT。每个对象都以一次流式腾讯 COS PUT 上传,并携带显式长度与 Content-MD5;COS SDK 仅在请求体不是流时才会重发请求,上传器自身也不重试。保留部分记录,检查远端状态后再执行下一次操作:超时或回执写入失败不能证明对象未存储。这些记录仅在本地,不防篡改,也不会自动备份;每次发布应将它们与构建证据一同归档到受控存储。公网 CDN 回读仍是单独的发布验收,上传结果明确标记为 `not-performed`。 @@ -173,13 +175,13 @@ Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` | `test` 或未设置 | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`、`DOWNLOAD_TEST_COS_SECRET_KEY` | | `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`、`DOWNLOAD_PROD_COS_SECRET_KEY` | -每个测试发布批次用下方命令生成新 ID,将输出填入 `.env.macos` 或 `.env.windows` 的 `DOWNLOAD_TEST_RELEASE_ID`。这两个被 Git 忽略的平台文件管理该值,shell 变量不能覆盖它,dotenv 值也不会进行 shell 展开。打包、上传和重试必须沿用同一个 ID;上传会拒绝更新 URL 不一致的完成记录。生产环境不使用该字段。 +每个测试发布批次用下方命令生成新 ID,将输出填入 `.env.macos` 或 `.env.windows` 的 `DOWNLOAD_TEST_RELEASE_ID`。这两个被 Git 忽略的平台文件管理该值,shell 变量不能覆盖它,dotenv 值也不会进行 shell 展开。打包、上传和重试必须沿用同一个 ID;上传会拒绝更新 URL 不一致的完成记录。需要验证跨版本升级时,后续版本沿用已安装客户端的 ID。生产环境不使用该字段。 ```sh -node --input-type=module -e 'import { randomBytes } from "node:crypto"; console.log(randomBytes(16).toString("hex"))' +node --input-type=module -e "import { randomBytes } from 'node:crypto'; console.log(randomBytes(16).toString('hex'))" ``` -通过完整下载链接分发每个测试批次。已安装的测试客户端保留当前批次的清单地址,不会自动发现新 ID。随机路径降低被猜中的概率,不限制持有链接者访问;撤下批次需要删除其 COS 对象并清除对应 CDN 目录缓存。 +通过完整下载链接分发每个测试批次。已安装的测试客户端保留当前批次的清单地址,不会自动发现新 ID。格式校验无法判断随机性,请使用生成器的输出。随机路径降低被猜中的概率,不限制持有链接者访问;撤下批次需要删除其 COS 对象并清除对应 CDN 目录缓存。 在目标 `.env` 中配置更新地址与所选 COS bucket、SecretId、SecretKey,再打包并上传同一个目标: diff --git a/apps/desktop/tests/installed-update/README.i18n.yaml b/apps/desktop/tests/installed-update/README.i18n.yaml index 8ea70c5cbc..036b9a3a62 100644 --- a/apps/desktop/tests/installed-update/README.i18n.yaml +++ b/apps/desktop/tests/installed-update/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/tests/installed-update/README.md -README.md: 139a80246d6539898cc870129fe55ad726b2673d -README.zh.md: 2b140cceb37ac104c1984c35322da0814ac365b7 +README.md: b847960862931a8c7ef769908d046cfb0f893d41 +README.zh.md: ac2bed4e2a0304f0362c36504128ff17fc5ba693 diff --git a/apps/desktop/tests/installed-update/README.md b/apps/desktop/tests/installed-update/README.md index 139a80246d..b847960862 100644 --- a/apps/desktop/tests/installed-update/README.md +++ b/apps/desktop/tests/installed-update/README.md @@ -44,7 +44,7 @@ Package both files from the generated `bootstrap` directory beside `lib/`, and s The `application` command copies built main/preload modules, renderer files, and the prepared bootstrap into `application/files`, with SHA-256 values in `application/result.json`. It does not copy the app's root `.env.windows` or freeze `node_modules` and build tools. The [qualification builder configuration](../../scripts/installed-update-builder.ts) verifies this inventory and the selected private runtime, selects the shared frozen files and isolated package metadata, and preserves ordinary installer and signing hooks. Its tests substitute the signer and pass the pinned builder's configuration validator; they do not produce an installer. A separately authorized supervised build must still record dependency/tool inputs and verify final package contents and signatures. -The [packaging entry](../../scripts/package-installed-update.ts) defaults to checks. A retained signing interlock rejects before credentials are loaded; it never clears that interlock. With no interlock, checks load `.env.windows`, validate prepared inputs, and launch no child. Run this from the repository root with one exact version: +The [packaging entry](../../scripts/package-installed-update.ts) defaults to checks. A retained signing interlock rejects before credentials are loaded; it never clears that interlock. With no interlock, checks load `.env.windows`, validate prepared inputs, and launch no child. The shared builder requires a valid `DOWNLOAD_TEST_RELEASE_ID` from the [test release settings](../../README.md#upload-updates); qualification still publishes through the manifest’s separate `qualification/` paths. Run this from the repository root with one exact version: ```powershell node --import tsx apps/desktop/scripts/package-installed-update.ts "" 0.1.6-alpha.1.20260916.1 --check diff --git a/apps/desktop/tests/installed-update/README.zh.md b/apps/desktop/tests/installed-update/README.zh.md index 2b140cceb3..ac2bed4e2a 100644 --- a/apps/desktop/tests/installed-update/README.zh.md +++ b/apps/desktop/tests/installed-update/README.zh.md @@ -44,7 +44,7 @@ node --import tsx apps/desktop/scripts/prepare-installed-update.ts application " `application` 命令将已构建的主程序/预加载模块、界面文件和准备好的启动入口复制到 `application/files`,并在 `application/result.json` 中记录 SHA-256。它不复制应用根目录的 `.env.windows`,也不冻结 `node_modules` 与构建工具。[验收打包配置](../../scripts/installed-update-builder.ts)校验该清单与选定的独立运行时,选用共享冻结文件和隔离包元数据,并保留常规安装器与签名钩子。其测试替换签名器,并通过固定版本构建器的配置校验;测试不生成安装器。另行授权的受监督构建仍须记录依赖/工具输入,并验证最终包内容及签名。 -[打包入口](../../scripts/package-installed-update.ts)默认只检查。保留的签名保护锁会在读取凭据前使其拒绝;入口绝不清除此锁。没有保护锁时,检查会加载 `.env.windows`、校验准备输入,不启动子进程。在仓库根目录使用一个准确版本执行: +[打包入口](../../scripts/package-installed-update.ts)默认只检查。保留的签名保护锁会在读取凭据前使其拒绝;入口绝不清除此锁。没有保护锁时,检查会加载 `.env.windows`、校验准备输入,不启动子进程。共享构建器要求[测试发布配置](../../README.zh.md#upload-updates)中有效的 `DOWNLOAD_TEST_RELEASE_ID`;资格验收仍使用清单中独立的 `qualification/` 路径发布。在仓库根目录使用一个准确版本执行: ```powershell node --import tsx apps/desktop/scripts/package-installed-update.ts "" 0.1.6-alpha.1.20260916.1 --check From e71df56b1a2b6511bc7184a58c6a6142e934bed9 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sun, 20 Sep 2026 14:59:15 +0800 Subject: [PATCH 03/60] feat(workspace): create the default workspace on first send --- ...ession-scope-and-provide-channel.i18n.yaml | 4 +- ...lient-session-scope-and-provide-channel.md | 8 +- ...nt-session-scope-and-provide-channel.zh.md | 8 +- .../2026-09-20-default-workspace.i18n.yaml | 6 + .../feature/2026-09-20-default-workspace.md | 32 ++++ .../2026-09-20-default-workspace.zh.md | 32 ++++ apps/web/tests/default-workspace.e2e.ts | 122 +++++++++++++++ .../default-workspace/failure.expected.md | 6 + apps/web/tests/scaffold.ts | 2 + apps/web/tests/support.ts | 23 +-- apps/web/tsconfig.json | 1 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 19 ++- docs/config-catalog.zh.md | 19 ++- docs/subsystems/workspace.i18n.yaml | 4 +- docs/subsystems/workspace.md | 23 +++ docs/subsystems/workspace.zh.md | 23 +++ .../api/workspace-controller/README.i18n.yaml | 4 +- packages/api/workspace-controller/README.md | 12 ++ .../api/workspace-controller/README.zh.md | 12 ++ .../api/workspace-controller/package.json | 2 + .../workspace-controller/src/client/index.ts | 2 +- .../workspace-controller/src/client/model.ts | 13 ++ .../src/client/service.ts | 15 +- .../src/default-directory.ts | 79 ++++++++++ .../api/workspace-controller/src/index.ts | 53 ++++++- .../api/workspace-controller/src/types.ts | 8 + .../tests/default-directory.host.spec.ts | 48 ++++++ .../tests/model.client.spec.ts | 17 +++ .../tests/remote/workspace.client.ts | 1 + .../tests/transport.client.spec.ts | 5 + .../tests/workspace-controller.host.spec.ts | 48 +++++- .../workspace-controller/tsconfig.host.json | 5 +- .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 2 + packages/client/ui-agent-preset/README.zh.md | 2 + .../ui-agent-preset/src/client/index.ts | 8 + .../ui-agent-preset/src/client/seat-store.ts | 13 +- .../tests/apply.client.spec.ts | 33 +++- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 6 +- packages/client/ui-conversation/README.zh.md | 6 +- .../ui-conversation/src/client/apply.ts | 41 +++-- .../src/client/contract/input.ts | 6 + .../src/client/contract/slots.ts | 13 +- .../ui-conversation/src/client/index.ts | 8 + .../src/client/input/facade.ts | 4 +- .../src/client/input/first-draft.ts | 79 ++++++++++ .../ui-conversation/src/client/locales.ts | 8 + .../client/skeleton/ConversationContent.tsx | 40 +++-- .../src/client/skeleton/ConversationRoot.tsx | 5 +- .../src/client/skeleton/InputBar.tsx | 81 ++++------ .../tests/apply-inject.client.spec.tsx | 52 ++++++- .../tests/assembly-surfaces.client.spec.tsx | 24 ++- .../tests/first-draft.client.spec.ts | 141 +++++++++++++++++ .../tests/input-bar.client.spec.tsx | 1 + .../tests/input-matrix.client.spec.tsx | 1 + .../tests/input-scenarios.client.spec.tsx | 1 + .../tests/skeleton.client.spec.tsx | 16 ++ packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 + packages/client/ui-workspace/README.zh.md | 2 + .../ui-workspace/src/client/navigation.ts | 33 +++- .../tests/workspaces-service.client.spec.ts | 40 +++++ .../extensions/tool-cordis/src/api-catalog.ts | 16 ++ .../client-runtime/src/workspaces.ts | 15 +- packages/workspace/workspace/README.i18n.yaml | 4 +- packages/workspace/workspace/README.md | 13 +- packages/workspace/workspace/README.zh.md | 13 +- packages/workspace/workspace/src/index.ts | 41 ++++- packages/workspace/workspace/src/spec.ts | 2 + .../workspace/tests/workspace.spec.ts | 144 +++++++++++++++++- pnpm-lock.yaml | 6 + scripts/gen-cordis-catalog.ts | 2 + snapshots/web/default-workspace/snapshot.yml | 9 ++ .../web/default-workspace/ui.expected.md | 26 ++++ tsconfig.host.json | 1 + 77 files changed, 1472 insertions(+), 160 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-20-default-workspace.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-20-default-workspace.md create mode 100644 .agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md create mode 100644 apps/web/tests/default-workspace.e2e.ts create mode 100644 apps/web/tests/expected/default-workspace/failure.expected.md create mode 100644 packages/api/workspace-controller/src/default-directory.ts create mode 100644 packages/api/workspace-controller/tests/default-directory.host.spec.ts create mode 100644 packages/client/ui-conversation/src/client/input/first-draft.ts create mode 100644 packages/client/ui-conversation/tests/first-draft.client.spec.ts create mode 100644 snapshots/web/default-workspace/snapshot.yml create mode 100644 snapshots/web/default-workspace/ui.expected.md diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index e891710dea..2e242505c5 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.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 .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: 990a9b6eef862a7ac4a549358beae7f9081eaa0b -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 5c90611fab36611766bb407138c4f0c8a6b3a01b +2026-07-25-web-client-session-scope-and-provide-channel.md: e2541836edc18755c2e49d30a10b44d2d3a7bcc3 +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: fd082cc74974a22a69a67cbf657693911aea8f99 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index 990a9b6eef..e2541836ed 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -26,8 +26,8 @@ The reference-owned lifetime and Provider targeting now follow [Client Session r Host-side `session.create(workspaceId)` produces Session + Agent + cwd in one piece (an atomic bundle, never split); the client side is the mirror of that birth — the instant a session row enters the list mirror, the client mints its Agent scope (actx + provide + the full input surface mounted): - Session identity is the host's true form from birth: the sessionId arrives via the `session.create` response / the `host/session-added` frame, and every client-side address (the scope tag, slot store keys, RPC addressing) uses that same id. -- The materialization moment = the instant the user picks a Workspace (cwd settled): the client calls `session.create({workspaceId})` on the spot and receives the complete entity. -- "New Session with no workspace picked" is a **pure view state** (a navigation position) corresponding to no session/scope entity; until the pick, the composer is locked whole (no slash, no plain text). +- A manual Workspace pick or successful [first-use default preparation](../feature/2026-09-20-default-workspace.md) establishes cwd before the client calls `session.create({workspaceId})` and receives the complete entity. +- "New Session with no workspace picked" has no Session or scope entity. The composer retains a local text draft; Session-scoped slash commands become available after connection. - A "blank session" is just an ordinary materialized session whose log is still empty; to every Agent-scope plugin on the host (goal/plan/skill/…) it is indistinguishable from any session, so slash/plan are all naturally live. ### Agent scope: the actx is the sole session carrier in the client-side cordis world @@ -95,7 +95,7 @@ Slot scope is the closed set `root | session-maybe | session`: - `session-maybe` inherits the nearest `SessionProvider` binding with ADOPTION identity: an incarnation born Session-less keeps its React instance when that Provider receives its first binding, then remounts when the Provider switches generation or returns to absence. Component-local per-Session state clears when the Provider switches generation. Across a switch, only persisted Store values survive generation retirement; binding-owned sources survive only when another reference keeps that generation alive. With no binding, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. Provider-roster changes rematerialize the mounted binding without changing its identity, while the per-entry adoption bookkeeping lives in the renderer's `SessionMaybeEntry`. - `session` guarantees that `sessionId`, every hook source, and every prop exist; each strict entry's error boundary is keyed by `sessionId`, so switching sessions recreates that entry and its session store. -`conversation` is the resident `session-maybe` shell under its owning `SessionProvider`: `ConversationRoot`, HeroShell, the Workspace picker, the scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-Session → blank-Session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same Session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no Session its machine faces and message actions are inert, while the whole dashed card opens the existing Workspace picker by pointer and its read-only textarea does the same through Enter or Space. The same instance — textarea included — goes live when a binding appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip. +`conversation` is the resident `session-maybe` shell under its owning `SessionProvider`: `ConversationRoot`, HeroShell, the Workspace picker, the scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-Session → blank-Session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same Session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: before a Session exists, its injected editor retains the first-use text draft; after connection, the same InputBar instance receives the retained Session's input. The remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip. Blank Sessions retain the header's leading and corner slots so navigation controls, including the right-sidebar opener, are available before the first message. Title, actions, utilities, and View tabs remain hidden in the blank phase. The header still requires a selected Session; the Files and Terminal entries use that Session's workspace and execution services without requiring a recorded Turn. @@ -136,5 +136,5 @@ Blank Sessions retain the header's leading and corner slots so navigation contro - Plugins gain session context isomorphic to the host's: per-session state hangs on the actx and mounts/tears down in one piece with the scope fiber, making leaks structurally impossible; two-session isolation is structurally guaranteed by the scope filter. - The client object layer converges to a wire mirror: session identity, lifecycle, and capability adjudication all defer to the host entity — the input system (the next layer) always faces a session with a real Agent, and providers like slash/skill uniformly address by sessionId directly. - Blank-session governance takes zero dedicated mechanisms: state rides one derived bit, visibility rides the unified list projection (only the current blank shows, as `New Session`), reuse acquires the persisted writer, and the ordinary ceiling rides same-Workspace reuse. -- The cost: the id→ctx handoff discipline and provide's Concurrent discipline are conventions rather than type-enforced, pinned by review and tests. The single state axis still withholds machine faces until a Session exists; the [resident conversation shell](../../../../packages/client/ui-conversation/README.md) routes activation to the Workspace picker during that interval. +- The cost: the id→ctx handoff discipline and provide's Concurrent discipline are conventions rather than type-enforced, pinned by review and tests. Session-scoped input remains unavailable until a Session exists; the [resident conversation shell](../../../../packages/client/ui-conversation/README.md) owns the local first-use draft during that interval. - Known gaps: approval/question recovery across prune (TODO); model selection returns in live-mutation shape (the host `selectModel` trio is ready-made, its client consumer not yet built). diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 5c90611fab..fd082cc749 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -26,8 +26,8 @@ web client 只有一张全局会话面:slot 全部从根上下文渲染,插 host 侧 `session.create(workspaceId)` 一体产出 Session + Agent + cwd(作为不可拆分的原子整体);client 侧就是这次出生的镜像——会话行进入 list mirror 的瞬间,client 为它铸 Agent scope(actx + provide + 输入面全套挂上): - 会话身份自出生即为 host 真身:sessionId 由 `session.create` 响应 / `host/session-added` 帧带来,client 侧一切寻址(scope tag、slot store 键、RPC 地址)用的都是同一个 id。 -- 实体化时点 = 用户选定 Workspace(cwd 确定)的瞬间:client 当场调 `session.create({workspaceId})`,拿到完整实体。 -- 「New Session 且未选 workspace」是**纯视图态**(一个导航位置),不对应任何会话/scope 实体;选定之前 composer 整体锁死(无 slash、无纯文本)。 +- 手动选择 Workspace 或成功[准备首次使用默认工作区](../feature/2026-09-20-default-workspace.zh.md)先确定 cwd,client 随后调用 `session.create({workspaceId})` 并取得完整实体。 +- 「New Session 且未选 workspace」没有 Session 或 scope 实体。Composer 保留本地文本草稿;连接后才提供 Session scope 的 slash 命令。 - 「空会话」就是一个日志还空着的普通实体化会话;对 host 上所有 Agent-scope 插件(goal/plan/skill(技能)/…)它与任何会话无异,slash/plan 天然全活。 ### Agent scope:actx 是 client 侧 cordis 世界的唯一会话载体 @@ -95,7 +95,7 @@ slot scope 是闭集 `root | session-maybe | session`: - `session-maybe` 以**收养(adoption)身份语义**继承最近 `SessionProvider` 的 binding:空态出生的化身在该 Provider 第一次收到 binding 时保持 React 实例,此后 Provider 切换 generation 或回到空态时重挂。Provider 切换 generation 时,组件本地的逐 Session 状态会清零。切换过程中,只有持久化 Store 值能活过 generation 退休;只有另一份 reference 保活该 generation 时,binding 自有 source 才能保留。无 binding 时,`sessionId`、`useSession`/`useInput` 的结果与 `inputActions` 均可缺省。Provider roster 变化会重新物化已挂载 binding,但不改变其 identity;逐 entry 的收养记账住在 renderer 的 `SessionMaybeEntry`。 - `session` 保证 `sessionId`、所有钩子 source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key,切换会话会重建该 entry 及其会话 store。 -`conversation` 是其 owner `SessionProvider` 下的 `session-maybe` 常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无 Session → blank Session 的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 Session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 Session 时,其 machine faces 和消息动作保持惰性,整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。binding 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不派发任何内容。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。 +`conversation` 是其 owner `SessionProvider` 下的 `session-maybe` 常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无 Session → blank Session 的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 Session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:Session 存在前,注入的编辑器保存首次使用文本草稿;连接后,同一 InputBar 实例接收已持有 Session 的输入。其余输入 slot 保持严格 `session`,在此之前不派发任何内容。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。 blank Session 保留 header 的 leading 与 corner slot,让右侧栏展开入口等导航控件在首条消息之前即可使用。标题、actions、utilities 和 View tabs 在 blank phase 中继续隐藏。header 仍要求已选中的 Session;Files 与 Terminal 入口使用该 Session 的工作区和执行服务,无需已有 Turn 记录。 @@ -136,5 +136,5 @@ blank Session 保留 header 的 leading 与 corner slot,让右侧栏展开入 - 插件获得与 host 同构的会话上下文:逐会话状态挂 actx、随 scope fiber 一次拆装,泄漏结构性不可能;双会话隔离由 scope filter 结构性保证。 - client 对象层收敛为 wire 镜像:会话身份、生命周期、能力判别全部以 host 实体为准——输入体系(下一层)面对的永远是「有真 Agent 的会话」,slash/skill 等提供方一律以 sessionId 直接寻址。 - 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 `New Session` 展示),复用时获取持久化写锁,常规上限靠同 Workspace 复用。 -- 代价:id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住。单一状态轴仍会在 Session 存在前隐藏 machine face;这段时间内,[常驻会话壳](../../../../packages/client/ui-conversation/README.zh.md)会把激活操作转到 Workspace picker。 +- 代价:id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住。Session 存在前,其 scope 输入仍不可用;这段时间内,[常驻会话壳](../../../../packages/client/ui-conversation/README.zh.md)负责本地首次使用草稿。 - 已知欠账:approval/question 跨 prune 恢复(TODO);模型选择以 live-mutation 形状回归(host `selectModel` 三件套现成,其 client 消费方尚未构建)。 diff --git a/.agents/notes/implemented/feature/2026-09-20-default-workspace.i18n.yaml b/.agents/notes/implemented/feature/2026-09-20-default-workspace.i18n.yaml new file mode 100644 index 0000000000..cdb2673df8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-default-workspace.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 .agents/notes/implemented/feature/2026-09-20-default-workspace.md +2026-09-20-default-workspace.md: d2a32d47a407dcf211c1cad8bb98f29847a77a3b +2026-09-20-default-workspace.zh.md: 6b0ec542da6e9e4fe76fb56b6562edea9e5777f4 diff --git a/.agents/notes/implemented/feature/2026-09-20-default-workspace.md b/.agents/notes/implemented/feature/2026-09-20-default-workspace.md new file mode 100644 index 0000000000..d2a32d47a4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-default-workspace.md @@ -0,0 +1,32 @@ +# Agent Note: First-use default Workspace + +Status: implemented + +English | [中文](2026-09-20-default-workspace.zh.md) + +## Problem + +A new installation requires a directory choice before the user can send a first message. Removing that prerequisite must preserve the Session's fixed working directory and must not treat hidden or archived history as a new installation. + +## Decision + +The conversation shell retains an unsent local text draft without creating a Session. Its first send asks the Host to prepare a default Workspace, then connects a real Session and submits through the ordinary composer pipeline. The first-use draft awaits staged mode selection before automatic submission. This partially supersedes the locked no-Session composer in [Session scope and provisioning](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.md); that note still owns blank-Session reuse and provisioning rationale. + +The [Workspace registry](../../../../packages/workspace/workspace/README.md#first-use-workspace) owns eligibility and directory preparation. All live Sessions, persisted headers, and archived identities count, including entries absent from the sidebar. The operation shares the registry mutation queue and rechecks Session history after directory preparation because Sessions can start independently. + +The Client resolves the initial directory name and title from its language when sending. The Host controller resolves the Documents location; the registry receives a directory resolver with no locale dependency. The resolver runs inside the mutation queue only for eligible creation, so repeated requests reuse the durable Workspace without another OS lookup. A durable Workspace id records successful initialization independently of that name. Renaming, changing language, restarting, or deleting the registration cannot initialize another default. The marker commits with the registration, so a failed registration can retry. Directory contents remain subject to the existing [metadata-only deletion policy](2026-07-27-workspace-registration-deletion.md). + +Creation failure preserves the draft and offers the existing folder picker. A successful registration survives Session creation or prompt failure. Picking a folder after failure transfers the text without sending it; a later send remains the user's action. + +## Alternatives considered + +- Creating on application startup would write directories for users who never send a message. +- Inferring first use from visible sidebar rows would ignore archived, hidden, and cwd-less Sessions. +- Using the localized path as the initialization marker would allow language changes or deletion to create another default. +- Changing a Session's cwd after creation would change the meaning of its recorded tools and attachments. + +## Consequences + +First-use typing has no Host Session or filesystem effects. Preparation remains a Host responsibility for both Desktop and remote Web clients, so remote users use the Host account's Documents location. Unavailable Documents lookup fails with the same folder-selection recovery as directory creation failure. + +The feature preserves [reference-owned Client Session lifetimes](../architecture/2026-09-15-client-session-references.md) and requires no agent-loop or Session-event change. Registry and client tests cover retries, hidden history, concurrency, and draft recovery; the [browser scenario](../../../../apps/web/tests/default-workspace.e2e.ts) verifies the assembled first-send and picker paths with an isolated Documents directory and recorded Session replay. diff --git a/.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md b/.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md new file mode 100644 index 0000000000..6b0ec542da --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md @@ -0,0 +1,32 @@ +# Agent Note: 首次使用默认 Workspace + +Status: implemented + +[English](2026-09-20-default-workspace.md) | 中文 + +## Problem + +新安装的应用要求用户先选目录才能发送第一条消息。去掉这一步前置操作时,必须保留 Session 的固定工作目录,也不能把隐藏或已归档的历史误判为新安装。 + +## Decision + +会话外壳保存未发送的本地文本草稿,不创建 Session。首次发送请求 Host 准备默认 Workspace,随后连接真实 Session,并通过常规 composer 流程提交。首次使用的草稿会在自动提交前等待暂存的模式选择完成。这部分取代[Session scope 与供给通道](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md)中无 Session 时锁定 composer 的决策;该记录仍负责 blank Session 复用与供给通道的理由。 + +[Workspace registry](../../../../packages/workspace/workspace/README.zh.md#first-use-workspace)负责资格判断和目录准备。所有 live Session、持久化 header 和已归档身份都计入判断,包括侧栏中不可见的条目。操作共用 registry 修改队列,并在目录准备后重新检查 Session 历史,因为 Session 可以独立启动。 + +Client 在发送时按其语言解析初始目录名和标题。Host 控制器解析 Documents 位置;注册表接收不依赖 locale 的目录解析器。解析器仅在允许创建时于变更队列内运行,因此重复请求直接复用持久化的 Workspace,无需再次查询操作系统目录。持久化 Workspace id 独立于名称记录初始化成功。改名、切换语言、重启或删除登记均不能再次初始化默认工作区。标记与登记一起提交,因此登记失败可以重试。目录内容仍遵循已有的[仅删除元数据策略](2026-07-27-workspace-registration-deletion.zh.md)。 + +创建失败时保留草稿,并提供现有文件夹选择器。成功的登记会在 Session 创建或提示词发送失败后保留。失败后选择文件夹只转移文本,不发送;随后发送仍由用户操作。 + +## Alternatives considered + +- 在应用启动时创建,会为从未发送消息的用户写入目录。 +- 根据侧栏可见行推断首次使用,会忽略已归档、隐藏和无 cwd 的 Session。 +- 将本地化路径作为初始化标记,会允许切换语言或删除后再次创建默认工作区。 +- 在 Session 创建后更改 cwd,会改变其已记录工具和附件的含义。 + +## Consequences + +首次使用时输入文本不会产生 Host Session 或文件系统副作用。Desktop 和远程 Web 客户端均由 Host 准备目录,因此远程用户使用 Host 账户的 Documents 位置。Documents 查询不可用时,采用与目录创建失败相同的文件夹选择恢复路径。 + +该功能保留[由引用持有的 Client Session 生命周期](../architecture/2026-09-15-client-session-references.zh.md),无需修改 agent-loop 或 Session 事件。Registry 和客户端测试覆盖重试、隐藏历史、并发及草稿恢复;[浏览器场景](../../../../apps/web/tests/default-workspace.e2e.ts)通过隔离的 Documents 目录和已录制 Session 回放验证完整装配下的首次发送与选择器路径。 diff --git a/apps/web/tests/default-workspace.e2e.ts b/apps/web/tests/default-workspace.e2e.ts new file mode 100644 index 0000000000..00bec9de3c --- /dev/null +++ b/apps/web/tests/default-workspace.e2e.ts @@ -0,0 +1,122 @@ +/** First-use messages prepare a durable Workspace through the shipped Web composition. */ +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import { describe, expect, it } from 'vitest' +import { + captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, + selectedSessionFixture, watchConsole, webSnapshotMode, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url)) +const EXPECTED = fileURLToPath(new URL('../../../snapshots/web/default-workspace/ui.expected.md', import.meta.url)) +const FAILURE_EXPECTED = fileURLToPath(new URL('./expected/default-workspace/failure.expected.md', import.meta.url)) +const MODE = webSnapshotMode() + +describe.skipIf(MODE === 'record')('web e2e: default Workspace', () => { + it('sends the first draft in a newly selected default Workspace and keeps its identity across reload', async () => { + const fixture = await selectedSessionFixture(FIXTURE) + const scaffold = await launchWebScaffold({ + replayFixture: fixture, + // The shared recording remains read-only; replay compares the complete persisted Session. + compareReplaySession: MODE === 'replay', + paceMs: 5, + }) + try { + const browser = await chromium.launch() + try { + const page = await newEnglishPage(browser) + const tripwire = watchConsole(page) + try { + await page.goto(scaffold.authenticatedUrl) + const input = page.locator('[data-composer-input][contenteditable="true"]').first() + await input.waitFor() + const prompt = fixtureUserPrompts(await readFile(fixture, 'utf8'))[0]! + await input.fill(prompt) + expect(scaffold.ctx.workspaceRegistry.list()).toEqual([]) + expect(scaffold.ctx.sessions.list()).toEqual([]) + const settled = scaffold.whenTurnSettled() + await input.press('Enter') + const sessionId = await settled + const workspace = scaffold.ctx.workspaceRegistry.list()[0]! + expect(workspace.title).toBe('Default workspace') + expect(workspace.path).toBe(join(scaffold.workspaceCwd, 'Documents', 'deepseek-harness', 'Default workspace')) + expect((await stat(workspace.path)).isDirectory()).toBe(true) + expect(workspace.sessionIds).toContain(sessionId) + expect(scaffold.ctx.sessions.get(sessionId)?.header.cwd).toBe(workspace.path) + await page.getByText('DONE', { exact: true }).waitFor() + await compareOrRefreshGolden(EXPECTED, await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), MODE) + await page.reload() + await page.getByText('DONE', { exact: true }).waitFor() + expect(scaffold.ctx.workspaceRegistry.list().map(item => item.id)).toEqual([workspace.id]) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } catch (error) { + await saveFailureShot(page, 'web-e2e-default-workspace') + throw error + } + } finally { + await browser.close() + } + } finally { + await scaffold.close() + } + }) + + it('keeps the draft on a directory conflict and opens the composed folder picker for recovery', async () => { + const scaffold = await launchWebScaffold() + try { + const parent = join(scaffold.workspaceCwd, 'Documents', 'deepseek-harness') + await mkdir(parent, { recursive: true }) + await writeFile(join(parent, '默认工作区'), 'occupied') + const chosen = join(scaffold.workspaceCwd, 'chosen') + await mkdir(chosen) + const browser = await chromium.launch() + try { + const page = await browser.newPage({ locale: 'zh-CN', timezoneId: 'Asia/Shanghai' }) + const tripwire = watchConsole(page) + try { + await page.goto(scaffold.authenticatedUrl) + const input = page.locator('[data-composer-input][contenteditable="true"]').first() + await input.fill('保留这条消息') + await input.press('Enter') + const modal = page.getByRole('dialog', { name: '无法创建默认工作区' }) + await modal.waitFor() + await expect.poll(() => input.textContent()).toBe('保留这条消息') + expect(scaffold.ctx.workspaceRegistry.list()).toEqual([]) + expect(scaffold.ctx.sessions.list()).toEqual([]) + await compareOrRefreshGolden(FAILURE_EXPECTED, await modal.ariaSnapshot(), MODE) + await modal.getByRole('button', { name: '取消', exact: true }).click() + expect(await input.textContent()).toBe('保留这条消息') + await input.press('Enter') + await modal.getByRole('button', { name: '选择文件夹', exact: true }).click() + const picker = page.getByRole('dialog', { name: '选择工作区目录' }) + await picker.waitFor() + await picker.getByRole('button', { name: '编辑路径', exact: true }).click() + await picker.getByRole('textbox', { name: '编辑路径' }).fill(chosen) + await page.keyboard.press('Enter') + await picker.getByRole('button', { name: '打开', exact: true }).click() + await picker.waitFor({ state: 'hidden' }) + await expect.poll(() => scaffold.ctx.workspaceRegistry.list().length).toBe(1) + await page.getByRole('treeitem', { name: '新会话', exact: true }).waitFor() + await expect.poll(() => input.textContent()).toBe('保留这条消息') + expect(scaffold.ctx.workspaceRegistry.list()[0]?.path).toBe(chosen) + expect(scaffold.ctx.sessions.list()).toHaveLength(1) + expect(scaffold.ctx.sessions.list().every(session => + session.snapshotEvents().every(event => event.type !== 'user/message'))).toBe(true) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } catch (error) { + await saveFailureShot(page, 'web-e2e-default-workspace-failure') + throw error + } + } finally { + await browser.close() + } + } finally { + await scaffold.close() + } + }) +}) diff --git a/apps/web/tests/expected/default-workspace/failure.expected.md b/apps/web/tests/expected/default-workspace/failure.expected.md new file mode 100644 index 0000000000..6944d1cf14 --- /dev/null +++ b/apps/web/tests/expected/default-workspace/failure.expected.md @@ -0,0 +1,6 @@ +- dialog "无法创建默认工作区": + - heading "无法创建默认工作区" [level=2] + - button "关闭" + - text: 请选择一个文件夹后重试 + - button "取消" + - button "选择文件夹" diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index e7249a0b67..75371e73b2 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -600,6 +600,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { } /** - * Drive the hero's workspace picker through the composed directory dialog - * until the live composer unlocks. A fresh world has no Workspace, so the boot - * lands in the Workspace-trigger view state (startup auto-selection has nothing to - * select); every scenario that types into the composer must connect one - * first. With nothing to list, activating the composer surface raises the dialog directly — - * adding a workspace is the picker's only entry. The directory is staged here - * and adopted through the path editor, which is idempotent across the repeated - * connects a scenario may make; creating a folder from inside the dialog (the - * product's other half of the same route) is covered by - * workspace-management.e2e.ts. The default name 'workspace' keeps the session - * header cwd at /workspace, the materialization proof several scenarios - * assert. + * Select a staged directory through the hero's composed Workspace picker. + * The default name keeps the Session cwd at /workspace for scenarios + * that require a specific directory instead of automatic first-use preparation. * @param page - the page under test. * @param root - host directory the workspace folder is staged in (the scaffold's `workspaceCwd`). * @param name - folder name staged and adopted as the workspace. */ export async function connectFreshWorkspace(page: Page, root: string, name = 'workspace'): Promise { mkdirSync(join(root, name), { recursive: true }) - await page.getByRole('textbox', { name: 'Choose workspace' }).click() + await page.getByRole('button', { name: 'Choose workspace', exact: true }).click() const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'Edit path' }).click() @@ -131,8 +122,7 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo await pathInput.fill(join(root, name)) await pathInput.press('Enter') await dialog.getByRole('button', { name: 'Open', exact: true }).click() - // The pick connected the workspace: the blank session's live composer - // replaces the locked placeholder and enables. + await page.getByRole('treeitem', { name: 'New Session', exact: true }).waitFor({ timeout: 15_000 }) await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]') .waitFor({ timeout: 15_000 }) } @@ -148,7 +138,7 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo */ export async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise { mkdirSync(join(root, name), { recursive: true }) - await page.getByRole('textbox', { name: '选择工作区' }).click() + await page.getByRole('button', { name: '选择工作区', exact: true }).click() const dialog = page.getByRole('dialog', { name: '选择工作区目录' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: '编辑路径' }).click() @@ -156,6 +146,7 @@ export async function connectFreshWorkspaceZh(page: Page, root: string, name = ' await pathInput.fill(join(root, name)) await pathInput.press('Enter') await dialog.getByRole('button', { name: '打开', exact: true }).click() + await page.getByRole('treeitem', { name: '新会话', exact: true }).waitFor({ timeout: 15_000 }) await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="描述你想要构建的内容, / 调用指令, @ 文件或对话"]') .waitFor({ timeout: 15_000 }) } diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 4508e03def..8560d4f99e 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -63,6 +63,7 @@ "tests/deepseek-messages-chat.e2e.ts", "tests/models-settings-recovery.e2e.ts", "tests/default-model.e2e.ts", + "tests/default-workspace.e2e.ts", "tests/github-ready-review.e2e.ts", "tests/streaming-fence-highlight.e2e.ts", "tests/declared-reasoning.e2e.ts", diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 106e1545fb..4274817f94 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: dad6b57ae0712e04ad16729b4067c541f83c68d6 -config-catalog.zh.md: 4ba0378810f3db4797c0d41fa5230f7069438b3f +config-catalog.md: c3dabe13807ef3ec2db7d861fdc93f9016260bc7 +config-catalog.zh.md: bd3b7e4dbc64627b6b9904360fcfb13f7f3da43e diff --git a/docs/config-catalog.md b/docs/config-catalog.md index dad6b57ae0..c3dabe1380 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -274,6 +274,24 @@ export interface Config { Source: [`packages/api/terminal-controller/src/index.ts:26`](../packages/api/terminal-controller/src/index.ts) + + +## `@deepseek-ai/dsh-api-workspace-controller` + +Requires: `typert` · `workspaceRegistry` + +```ts config-catalog +/** First-use directory policy for the Host account. */ +export interface Config { + /** Override the system Documents directory with a fully qualified path. */ + documentsDirectory?: string + /** Maximum duration of the operating system's Documents lookup. */ + documentsLookupTimeoutMs?: number +} +``` + +Source: [`packages/api/workspace-controller/src/index.ts:34`](../packages/api/workspace-controller/src/index.ts) + ## `@deepseek-ai/dsh-api-workspace-files` @@ -3783,7 +3801,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-acp-app` — requires `cmdlineArgs` ([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts)) - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) - `@deepseek-ai/dsh-api-remotes` — requires `typertGateway` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) -- `@deepseek-ai/dsh-api-workspace-controller` — requires `typert` · `workspaceRegistry` ([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) - `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-browser-use` ([`packages/browser-use/browser-use/src/index.ts`](../packages/browser-use/browser-use/src/index.ts)) - `@deepseek-ai/dsh-client-file-upload` — requires `agents` · `attachments` · `commands` · `connection` ([`packages/client/file-upload/src/index.ts`](../packages/client/file-upload/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4ba0378810..bd3b7e4dbc 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -276,6 +276,24 @@ export interface Config { 来源: [`packages/api/terminal-controller/src/index.ts:26`](../packages/api/terminal-controller/src/index.ts) + + +## `@deepseek-ai/dsh-api-workspace-controller` + +需要:`typert` · `workspaceRegistry` + +```ts config-catalog +/** First-use directory policy for the Host account. */ +export interface Config { + /** Override the system Documents directory with a fully qualified path. */ + documentsDirectory?: string + /** Maximum duration of the operating system's Documents lookup. */ + documentsLookupTimeoutMs?: number +} +``` + +源码:[`packages/api/workspace-controller/src/index.ts:34`](../packages/api/workspace-controller/src/index.ts) + ## `@deepseek-ai/dsh-api-workspace-files` @@ -3785,7 +3803,6 @@ export interface Config { - `@deepseek-ai/dsh-acp-app` — 需要 `cmdlineArgs`([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts)) - `@deepseek-ai/dsh-agent`([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) - `@deepseek-ai/dsh-api-remotes` — 需要 `typertGateway`([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) -- `@deepseek-ai/dsh-api-workspace-controller` — 需要 `typert` · `workspaceRegistry`([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) - `@deepseek-ai/dsh-authorization` — 需要 `credentials`([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-browser-use` ([`packages/browser-use/browser-use/src/index.ts`](../packages/browser-use/browser-use/src/index.ts)) - `@deepseek-ai/dsh-client-file-upload` — 需要 `agents` · `attachments` · `commands` · `connection`([`packages/client/file-upload/src/index.ts`](../packages/client/file-upload/src/index.ts)) diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index db7bb548ef..a7a3e3bb91 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.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/workspace.md -workspace.md: 9fe5f2bf4c62ba7d4f878b7b2f34490f43ca0b81 -workspace.zh.md: 63681e1a459c81a327654cfafa1f5cb2f231d6c8 +workspace.md: b68acd965b55df462a0751d0b7e3230c82a62172 +workspace.zh.md: 074f59438eb16a4ffb1423540c3e1868857ae40f diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index 9fe5f2bf4c..b68acd965b 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -121,6 +121,10 @@ Ownership truth is the record's ordered `sessionIds`, never derived from session Sessions get their cwd at create time from whoever creates them, not from this registry — the API gateway resolves a new session's cwd from the chosen workspace's `path` (falling back to an explicit or default cwd), creates the session so the cwd lands in its immutable [`SessionHeader`](persistence.md#sessionheader--metadata-beside-the-log), then calls `attachSession`, which re-validates that stored header cwd against the workspace path. On the first successful start, the registry bootstraps history from persisted headers alone (`id`, `cwd`, `createdAt` — never event bodies), grouping sessions with a valid canonical cwd into per-directory workspaces, newest first; the initialized marker is written last so an interrupted bootstrap resumes safely. The bootstrap is one-time: cwd-less legacy sessions stay Ungrouped, and sessions created afterwards join a workspace only through `attachSession`. +## Default Workspace initialization + +The controller's [transport types](../../packages/api/workspace-controller/src/types.ts) define `WorkspaceInitializeDefaultRequest`: a Client-resolved `directoryName` and initial `title`. The Host resolves the Documents location and asks the registry to initialize once. Locale selection belongs to the Client; the registry accepts a directory resolver and commits the registration with its durable identity. [First-use behavior and configuration](../../packages/api/workspace-controller/README.md#first-use-workspace) describe reuse and failure handling. + ## Session pinning The controller's [transport types](../../packages/api/workspace-controller/src/types.ts) define `WorkspacePinSessionRequest` and `WorkspaceUnpinSessionRequest`, each carrying one `sessionId`. Both operations return `WorkspacePinValue`: the complete `pinnedSessionIds` array of Session ids, most recently pinned first. Pinning requires a known, unarchived Session; unpinning an id that is not pinned succeeds without changing the set. Archiving removes the Session's pin in the same durable write, and unarchiving does not restore it. @@ -301,6 +305,14 @@ Host service backing the generated `ctx.remote.workspace` namespace. */ @Remote('create') create(request: WorkspaceCreateRequest): Promise +/** + * Initialize or reuse the default Workspace before the first user message. + * @param request - initial directory name and title; never rename an existing default. + * @param signal - caller lifetime; cancels native directory lookup. + * @returns the durable Workspace without creating a Session or sending a message. + */ +@Remote('initializeDefault') async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise + /** * Rename one Workspace to a unique non-blank title. * @param request - Workspace identity and proposed title. @@ -466,6 +478,17 @@ Durable workspace registry. Startup waits for `sessionPersistence`, builds one c */ async create(path: string, title?: string): Promise +/** + * Initialize the default Workspace only while both the registry and Session + * history are empty. Repeated requests reuse its durable identity; deleting + * that registration permanently disables automatic creation. + * @param resolveDirectory - resolve the absolute directory and initial title; + * called only for eligible creation, inside the registry mutation queue. + * Missing directories are created recursively before registration. + * @returns the initialized Workspace, or undefined when automatic creation is ineligible. + */ +initializeDefault(resolveDirectory: () => Promise<{ path: string; title: string }>): Promise + /** * Look up a workspace by id. * @param id - Workspace id. diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index 63681e1a45..074f59438e 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -121,6 +121,10 @@ interface Workspace { 会话的 cwd 在创建时由创建者赋予,而不是由本注册表赋予——API 网关从所选工作区的 `path` 解析新会话的 cwd(回退到显式或默认 cwd),先创建会话使 cwd 落入其不可变的 [`SessionHeader`](persistence.zh.md#sessionheader--metadata-beside-the-log),再调用 `attachSession`,后者会把已存储的 header cwd 与工作区路径重新校验一遍。首次成功启动时,注册表仅凭已持久化的 header(`id`、`cwd`、`createdAt`——绝不读事件正文)引导历史:把规范 cwd 有效的会话按目录分组为工作区,最新的排在最前;「已初始化」标记最后写入,因此被中断的引导可以安全续跑。引导只发生这一次:没有 cwd 的历史遗留会话保持 Ungrouped,此后创建的会话只能通过 `attachSession` 加入工作区。 +## 默认工作区初始化 + +控制器的[传输类型](../../packages/api/workspace-controller/src/types.ts)定义了 `WorkspaceInitializeDefaultRequest`,包含 Client 解析出的 `directoryName` 和初始 `title`。Host 解析 Documents 位置,并请求注册表执行一次初始化。语言选择由 Client 负责;注册表接收目录解析器,并将登记与持久化身份一起提交。[首次使用行为与配置](../../packages/api/workspace-controller/README.zh.md#first-use-workspace)说明复用和失败处理。 + ## 会话置顶 控制器的[传输类型](../../packages/api/workspace-controller/src/types.ts)定义了 `WorkspacePinSessionRequest` 和 `WorkspaceUnpinSessionRequest`,两者都携带一个 `sessionId`。两个操作都返回 `WorkspacePinValue`:完整的会话 id 数组 `pinnedSessionIds`,最近置顶的会话排在前面。置顶要求会话已知且未归档;对未置顶的 id 取消置顶会成功,且不改变集合。归档在同一次持久化写入中移除该会话的置顶,取消归档不会恢复置顶。 @@ -301,6 +305,14 @@ Host service backing the generated `ctx.remote.workspace` namespace. */ @Remote('create') create(request: WorkspaceCreateRequest): Promise +/** + * Initialize or reuse the default Workspace before the first user message. + * @param request - initial directory name and title; never rename an existing default. + * @param signal - caller lifetime; cancels native directory lookup. + * @returns the durable Workspace without creating a Session or sending a message. + */ +@Remote('initializeDefault') async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise + /** * Rename one Workspace to a unique non-blank title. * @param request - Workspace identity and proposed title. @@ -466,6 +478,17 @@ Durable workspace registry. Startup waits for `sessionPersistence`, builds one c */ async create(path: string, title?: string): Promise +/** + * Initialize the default Workspace only while both the registry and Session + * history are empty. Repeated requests reuse its durable identity; deleting + * that registration permanently disables automatic creation. + * @param resolveDirectory - resolve the absolute directory and initial title; + * called only for eligible creation, inside the registry mutation queue. + * Missing directories are created recursively before registration. + * @returns the initialized Workspace, or undefined when automatic creation is ineligible. + */ +initializeDefault(resolveDirectory: () => Promise<{ path: string; title: string }>): Promise + /** * Look up a workspace by id. * @param id - Workspace id. diff --git a/packages/api/workspace-controller/README.i18n.yaml b/packages/api/workspace-controller/README.i18n.yaml index cd584226a9..e7bc836965 100644 --- a/packages/api/workspace-controller/README.i18n.yaml +++ b/packages/api/workspace-controller/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/api/workspace-controller/README.md -README.md: 46556b0105bb5186cf27b56faee4127b358eac9b -README.zh.md: 9d210c10486a71772cf6c0a10480b734fa992441 +README.md: 275c51d0018dda5b2209cc7bece71b618e7d113d +README.zh.md: 92ca3f86f11ce77b51f834ce7c24f195095065b1 diff --git a/packages/api/workspace-controller/README.md b/packages/api/workspace-controller/README.md index 46556b0105..275c51d001 100644 --- a/packages/api/workspace-controller/README.md +++ b/packages/api/workspace-controller/README.md @@ -26,6 +26,18 @@ The Host controller serializes mutations whose correctness depends on current re The Client entry provides `ClientWorkspaceModel` and `createWorkspaceStateStream()`. The model owns Workspace rows, registry order, archived and pinned Session identities, unary mutation echoes, and stream/unary race resolution. A newer Host row wins by `updatedAt`; a committed stream order outranks an older unary response; a removed Workspace id cannot be resurrected by delayed data. Pin snapshots change only when their identities or order change. The package exposes framework-neutral snapshots and subscriptions, leaving navigation policy and React hooks to the UI owner. + +### First-use Workspace + +`workspace.initializeDefault({ directoryName, title })` returns the durable default Workspace; the Client service exposes the same request as `workspaces.initializeDefault(request, signal?)`. The [Conversation Client](../../client/ui-conversation/README.md) resolves these names from its send-time language. The Host places the directory under its account's `/deepseek-harness`, including on remote Web hosts. The directory name must be one non-blank segment without separators, colon, NUL, surrounding whitespace, or a trailing dot; the title must be non-blank. Linux system lookup requires `xdg-user-dir` with an enabled Documents directory. + +The [Workspace registry](../../workspace/workspace/README.md#first-use-workspace) owns eligibility, directory creation, and durable initialization. An existing default Workspace is returned without another Documents lookup; request names do not rename it. Invalid names or ineligible first use reject with `gateway/bad-request`; lookup and creation failures use standard Remote error handling. Initialization creates no Session and sends no message. + +| Configuration | Default | Purpose | +| --- | --- | --- | +| `documentsDirectory` | System Documents directory | Fully qualified Host directory override | +| `documentsLookupTimeoutMs` | `10000` | Positive maximum duration of OS directory lookup, in milliseconds | + ----- diff --git a/packages/api/workspace-controller/README.zh.md b/packages/api/workspace-controller/README.zh.md index 9d210c1048..92ca3f86f1 100644 --- a/packages/api/workspace-controller/README.zh.md +++ b/packages/api/workspace-controller/README.zh.md @@ -26,6 +26,18 @@ Host 控制器会串行执行正确性取决于当前注册表状态的变更, Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。该模型拥有 Workspace 行、registry 顺序、归档与置顶会话身份、一元变更回显,以及流与一元调用的竞态处理。较新的 Host 行按 `updatedAt` 获胜;已提交的流顺序优先于较旧的一元响应;已经移除的 Workspace id 不会被延迟数据复活。置顶快照仅在会话身份或顺序变化时更新。该包公开与框架无关的快照和订阅,把导航策略与 React 钩子留给 UI owner。 + +### 首次使用工作区 + +`workspace.initializeDefault({ directoryName, title })` 返回持久化的默认工作区;Client service 通过 `workspaces.initializeDefault(request, signal?)` 提供同一请求。[Conversation Client](../../client/ui-conversation/README.zh.md)按发送时的语言解析这些名称。Host 将目录放在其账户的 `/deepseek-harness` 下,远程 Web Host 也遵循此规则。目录名必须是非空的单个片段,不能含分隔符、冒号、NUL、首尾空白或末尾句点;标题不能为空。Linux 系统查询要求存在 `xdg-user-dir` 且启用了 Documents 目录。 + +[Workspace 注册表](../../workspace/workspace/README.zh.md#first-use-workspace)负责资格判断、目录创建和持久化初始化。已有默认工作区直接返回,不再查询 Documents;请求中的名称不会将其重命名。名称无效或不满足首次使用条件时,以 `gateway/bad-request` 拒绝;查询和创建失败遵循标准 Remote 错误处理。初始化不创建 Session,也不发送消息。 + +| 配置 | 默认值 | 用途 | +| --- | --- | --- | +| `documentsDirectory` | 系统 Documents 目录 | 完全限定的 Host 目录覆盖值 | +| `documentsLookupTimeoutMs` | `10000` | 操作系统目录查询的正数最大时长,单位为毫秒 | + ----- diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index 48881e37ce..8085412c62 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -66,6 +66,8 @@ "license": "MIT", "dependencies": { "@deepseek-ai/dsh-deque": "workspace:^", + "@deepseek-ai/dsh-native-command": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^", "zod": "^4.4.3" }, "peerDependencies": { diff --git a/packages/api/workspace-controller/src/client/index.ts b/packages/api/workspace-controller/src/client/index.ts index 9a1af4c2e7..a9a98085f8 100644 --- a/packages/api/workspace-controller/src/client/index.ts +++ b/packages/api/workspace-controller/src/client/index.ts @@ -17,7 +17,7 @@ export type { } from './model.ts' export { WorkspaceController, WorkspaceCreateError } from './service.ts' export type { IWorkspaces, WorkspaceSource } from './service.ts' -export type { WorkspaceId, WorkspaceView } from '../types.ts' +export type { WorkspaceId, WorkspaceInitializeDefaultRequest, WorkspaceView } from '../types.ts' type WorkspaceBaselineFrame = Extract diff --git a/packages/api/workspace-controller/src/client/model.ts b/packages/api/workspace-controller/src/client/model.ts index 570b70aeb3..3f6dda9f76 100644 --- a/packages/api/workspace-controller/src/client/model.ts +++ b/packages/api/workspace-controller/src/client/model.ts @@ -12,6 +12,7 @@ import type { WorkspaceCreateValue, WorkspaceDeleteValue, WorkspaceInsertSessionBeforeRequest, + WorkspaceInitializeDefaultRequest, WorkspaceOrderValue, WorkspacePinSessionRequest, WorkspacePinValue, @@ -101,6 +102,18 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink { return result } + /** + * Initialize the default Workspace and merge its authoritative row. + * @param request - initial directory name and title. + * @param signal - caller lifetime. + * @returns generated Remote result. + */ + async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise> { + const result = await this.remote.initializeDefault(request, signal) + if (result.ok) this.upsert(result.value.workspace) + return result + } + /** * Rename a Workspace and merge the unary result immediately. * @param workspaceId - target Workspace. diff --git a/packages/api/workspace-controller/src/client/service.ts b/packages/api/workspace-controller/src/client/service.ts index 00998f8519..f94baafdb2 100644 --- a/packages/api/workspace-controller/src/client/service.ts +++ b/packages/api/workspace-controller/src/client/service.ts @@ -4,7 +4,7 @@ import { Service, type Context } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' -import type { WorkspaceView } from '../types.ts' +import type { WorkspaceInitializeDefaultRequest, WorkspaceView } from '../types.ts' import type { ClientWorkspaceModel, WorkspaceSnapshot } from './model.ts' /** Structured create failure for callers that distinguish Host business errors. */ @@ -39,6 +39,13 @@ export interface IWorkspaces { * @returns the created or idempotently resolved Workspace. */ create(input: { path: string }): Promise + /** + * Initialize or reuse the default Workspace. + * @param request - initial directory name and title. + * @param signal - caller lifetime. + * @returns the prepared Workspace; rejects when directory selection is required. + */ + initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise /** * Rename a Workspace. * @param workspaceId - target Workspace. @@ -110,6 +117,12 @@ export class WorkspaceController extends Service implements IWorkspaces { return result.value.workspace } + async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise { + const result = await this.model.initializeDefault(request, signal) + if (!result.ok) throw new WorkspaceCreateError(result.error) + return result.value.workspace + } + async rename(workspaceId: WorkspaceId, title: string): Promise { const result = await this.model.rename(workspaceId, title) if (!result.ok) throw commandError('rename', result.error) diff --git a/packages/api/workspace-controller/src/default-directory.ts b/packages/api/workspace-controller/src/default-directory.ts new file mode 100644 index 0000000000..2001b903c6 --- /dev/null +++ b/packages/api/workspace-controller/src/default-directory.ts @@ -0,0 +1,79 @@ +/** Resolve the Host account's Documents directory for first-use Workspace creation. */ + +import { homedir } from 'node:os' +import { posix, win32 } from 'node:path' +import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command' + +/** Platform observations replaceable in directory-resolution tests. */ +interface DocumentsDirectoryInternals { + readonly platform?: NodeJS.Platform + readonly home?: string + readonly run?: NativeCommandRunner +} + +/** + * Validate a configured or OS-returned Documents path without resolving it against cwd. + * @param directory - fully qualified directory spelling. + * @param platform - Host platform. + * @returns the normalized directory. + */ +export function validateDocumentsDirectory(directory: string, platform: NodeJS.Platform = process.platform): string { + const paths = platform === 'win32' ? win32 : posix + const root = paths.parse(directory).root + if (!paths.isAbsolute(directory) || (platform === 'win32' && (root === '\\' || root === '/'))) { + throw new Error(`Documents directory must be fully qualified: '${directory}'`) + } + return paths.normalize(directory) +} + +/** + * Resolve the first-use directory on the Host without creating files. + * @param directoryName - validated single directory name supplied by the Client. + * @param documentsDirectory - explicit deployment override for the system Documents directory. + * @param signal - caller lifetime and lookup deadline. + * @param internals - platform facts and native command runner. + * @returns the absolute candidate path. + */ +export async function defaultWorkspaceDirectory( + directoryName: string, + documentsDirectory: string | undefined, + signal: AbortSignal, + internals: DocumentsDirectoryInternals = {}, +): Promise { + const platform = internals.platform ?? process.platform + const paths = platform === 'win32' ? win32 : posix + signal.throwIfAborted() + let directory = documentsDirectory + if (directory === undefined) { + const run = internals.run ?? runNativeCommand + let stdout: string + switch (platform) { + case 'darwin': + ({ stdout } = await run('osascript', [ + '-e', 'POSIX path of (path to documents folder from user domain without folder creation)', + ], signal)) + break + case 'win32': + ({ stdout } = await run('powershell.exe', [ + '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', + '[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ' + + '[Environment]::GetFolderPath([Environment+SpecialFolder]::MyDocuments, ' + + '[Environment+SpecialFolderOption]::DoNotVerify)', + ], signal)) + break + case 'linux': + ({ stdout } = await run('xdg-user-dir', ['DOCUMENTS'], signal)) + break + default: + throw new Error(`system Documents directory is unavailable on ${platform}`) + } + directory = stdout.replace(/[\r\n]+$/, '') + // XDG reports the home directory when this user directory is disabled. + if (directory === '' || (platform === 'linux' && paths.normalize(directory) === (internals.home ?? homedir()))) { + throw new Error('system Documents directory is unavailable') + } + } + directory = validateDocumentsDirectory(directory, platform) + signal.throwIfAborted() + return paths.join(directory, 'deepseek-harness', directoryName) +} diff --git a/packages/api/workspace-controller/src/index.ts b/packages/api/workspace-controller/src/index.ts index deb547e791..2281273e1e 100644 --- a/packages/api/workspace-controller/src/index.ts +++ b/packages/api/workspace-controller/src/index.ts @@ -1,10 +1,12 @@ /** Host Workspace Remote owner: explicit commands and reconnect-safe state. */ import { Context } from '@deepseek-ai/cordis' -import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import z from '@deepseek-ai/schemastery' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { WorkspaceCommands } from './commands.ts' import { DirectoryPickerController } from './directory-picker.ts' -import { WorkspaceFeed } from './feed.ts' +import { WorkspaceFeed, workspaceView } from './feed.ts' +import { defaultWorkspaceDirectory, validateDocumentsDirectory } from './default-directory.ts' import type { WorkspaceArchiveSessionRequest, WorkspaceArchiveValue, @@ -14,6 +16,7 @@ import type { WorkspaceDeleteValue, WorkspaceFollowFrame, WorkspaceInsertBeforeRequest, + WorkspaceInitializeDefaultRequest, WorkspaceInsertSessionBeforeRequest, WorkspaceOrderValue, WorkspacePinSessionRequest, @@ -27,6 +30,14 @@ import type { export type * from './types.ts' export { DirectoryPickerController } from './directory-picker.ts' +/** First-use directory policy for the Host account. */ +export interface Config { + /** Override the system Documents directory with a fully qualified path. */ + documentsDirectory?: string + /** Maximum duration of the operating system's Documents lookup. */ + documentsLookupTimeoutMs?: number +} + declare module '@deepseek-ai/cordis' { interface Context { /** Host Workspace business API and Remote namespace owner. */ @@ -38,12 +49,20 @@ declare module '@deepseek-ai/cordis' { export class WorkspaceController extends TypertRemoteService { static inject = ['typert', 'workspaceRegistry'] + static Config: z = z.object({ + documentsDirectory: z.string(), + documentsLookupTimeoutMs: z.natural().min(1).default(10_000), + }) + + private readonly config: Config private readonly commands: WorkspaceCommands private readonly feed: WorkspaceFeed - /** @param ctx - Host context containing the Workspace registry. */ - constructor(ctx: Context) { + /** @param ctx - Host context containing the Workspace registry. @param config - first-use directory policy. */ + constructor(ctx: Context, config: Config = {}) { super(ctx, 'workspaceController', { namespace: 'workspace' }) + this.config = WorkspaceController.Config(config) + if (this.config.documentsDirectory !== undefined) validateDocumentsDirectory(this.config.documentsDirectory) this.commands = new WorkspaceCommands(ctx) this.feed = new WorkspaceFeed(ctx) // This package is the Loader entry for both Remote owners it hosts: the @@ -63,6 +82,32 @@ export class WorkspaceController extends TypertRemoteService { return this.commands.create(request) } + /** + * Initialize or reuse the default Workspace before the first user message. + * @param request - initial directory name and title; never rename an existing default. + * @param signal - caller lifetime; cancels native directory lookup. + * @returns the durable Workspace without creating a Session or sending a message. + */ + @Remote('initializeDefault') + async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise { + const { directoryName, title } = request + if (directoryName.trim() === '' || directoryName !== directoryName.trim() + || directoryName.endsWith('.') || /[/\\:\0]/.test(directoryName) || title.trim() === '') { + throw new RemoteError('gateway/bad-request', 'default Workspace requires a directory name and non-blank title', {}) + } + const workspace = await this.ctx.workspaceRegistry.initializeDefault(async () => { + const timeout = AbortSignal.timeout(this.config.documentsLookupTimeoutMs as number) + const path = await defaultWorkspaceDirectory( + directoryName, this.config.documentsDirectory, AbortSignal.any([signal, timeout]), + ) + return { path, title } + }) + if (workspace === undefined) { + throw new RemoteError('gateway/bad-request', 'choose a directory to create a Workspace', {}) + } + return { workspace: workspaceView(workspace) } + } + /** * Rename one Workspace to a unique non-blank title. * @param request - Workspace identity and proposed title. diff --git a/packages/api/workspace-controller/src/types.ts b/packages/api/workspace-controller/src/types.ts index f2b78e024b..e69c2b8c91 100644 --- a/packages/api/workspace-controller/src/types.ts +++ b/packages/api/workspace-controller/src/types.ts @@ -54,6 +54,14 @@ export interface WorkspaceCreateRequest { readonly path: string } +/** Names chosen by the first-use Client before default Workspace initialization. */ +export interface WorkspaceInitializeDefaultRequest { + /** Single non-blank name; no separators, colon, NUL, surrounding whitespace, or trailing dot. */ + readonly directoryName: string + /** Initial display title, retained across language changes. */ + readonly title: string +} + /** Created or previously registered Workspace. */ export interface WorkspaceCreateValue { readonly workspace: WorkspaceView diff --git a/packages/api/workspace-controller/tests/default-directory.host.spec.ts b/packages/api/workspace-controller/tests/default-directory.host.spec.ts new file mode 100644 index 0000000000..5878f42432 --- /dev/null +++ b/packages/api/workspace-controller/tests/default-directory.host.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest' +import type { NativeCommandRunner } from '@deepseek-ai/dsh-native-command' +import { defaultWorkspaceDirectory, validateDocumentsDirectory } from '../src/default-directory.ts' + +describe('system Documents directory', () => { + it.each([ + ['darwin', '/Users/a/文档/\n', '/Users/a/文档/deepseek-harness/默认工作区', 'osascript'], + ['win32', 'D:\\Redirected Documents\r\n', 'D:\\Redirected Documents\\deepseek-harness\\默认工作区', 'powershell.exe'], + ['linux', '/home/a/My Documents\n', '/home/a/My Documents/deepseek-harness/默认工作区', 'xdg-user-dir'], + ] as const)('uses the %s account directory and preserves spaces and Unicode', async (platform, stdout, path, command) => { + const run = vi.fn(async () => ({ stdout, stderr: '' })) + const signal = new AbortController().signal + await expect(defaultWorkspaceDirectory('默认工作区', undefined, signal, { platform, run })).resolves.toBe(path) + expect(run).toHaveBeenCalledWith(command, expect.any(Array), signal) + }) + + it.each(['Default workspace', '默认工作区', 'default-workspace'])('uses the configured directory with name %s', async (folder) => { + const run = vi.fn() + await expect(defaultWorkspaceDirectory(folder, '/documents', new AbortController().signal, { platform: 'linux', run })) + .resolves.toBe(`/documents/deepseek-harness/${folder}`) + expect(run).not.toHaveBeenCalled() + }) + + it.each(['', '\r\n', '/home/a\n'])('rejects an unavailable XDG directory %j', async (stdout) => { + const run: NativeCommandRunner = async () => ({ stdout, stderr: '' }) + await expect(defaultWorkspaceDirectory('Default workspace', undefined, new AbortController().signal, { platform: 'linux', home: '/home/a', run })) + .rejects.toThrow('unavailable') + }) + + it('propagates lookup failure and cancellation', async () => { + const run: NativeCommandRunner = async () => { throw new Error('lookup denied') } + await expect(defaultWorkspaceDirectory('Default workspace', undefined, new AbortController().signal, { platform: 'darwin', run })) + .rejects.toThrow('lookup denied') + await expect(defaultWorkspaceDirectory('Default workspace', '/documents', AbortSignal.abort(), { platform: 'linux' })) + .rejects.toThrow() + await expect(defaultWorkspaceDirectory('Default workspace', undefined, new AbortController().signal, { platform: 'freebsd' })) + .rejects.toThrow('unavailable') + }) + + it.each(['relative', 'C:relative', '\\rooted'])('rejects a Windows path without a fully qualified root: %s', (path) => { + expect(() => validateDocumentsDirectory(path, 'win32')).toThrow('fully qualified') + }) + + it('accepts redirected UNC Documents directories', () => { + expect(validateDocumentsDirectory('\\\\server\\share\\Documents', 'win32')).toBe('\\\\server\\share\\Documents') + expect(() => validateDocumentsDirectory('relative', 'linux')).toThrow('fully qualified') + }) +}) diff --git a/packages/api/workspace-controller/tests/model.client.spec.ts b/packages/api/workspace-controller/tests/model.client.spec.ts index b126805183..4558d4f64d 100644 --- a/packages/api/workspace-controller/tests/model.client.spec.ts +++ b/packages/api/workspace-controller/tests/model.client.spec.ts @@ -68,6 +68,7 @@ function deferred(): Deferred { } class FakeWorkspaceRemote implements WorkspaceRemote { + readonly initializeDefault = vi.fn(async () => remoteOk({ workspace: workspace('default') })) readonly calls: Array<{ readonly method: string; readonly request: unknown }> = [] onCreate: (request: WorkspaceCreateRequest) => Promise> = request => Promise.resolve(remoteOk({ workspace: workspace(request.path.split('/').pop() ?? 'workspace'), created: true })) @@ -167,6 +168,22 @@ function baseline( } describe('ClientWorkspaceModel', () => { + it('publishes the prepared Workspace and leaves the list unchanged on refusal', async () => { + const remote = new FakeWorkspaceRemote() + const model = modelFor(remote) + baseline(model) + const signal = new AbortController().signal + await expect(model.initializeDefault({ directoryName: '默认工作区', title: '默认工作区' }, signal)).resolves.toMatchObject({ ok: true }) + expect(remote.initializeDefault).toHaveBeenCalledWith({ directoryName: '默认工作区', title: '默认工作区' }, signal) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['default']) + const before = model.getSnapshot() + remote.initializeDefault.mockResolvedValueOnce(workspaceError( + new RemoteError('gateway/bad-request', 'choose a folder', {}), + )) + await expect(model.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' })).resolves.toMatchObject({ ok: false }) + expect(model.getSnapshot()).toBe(before) + }) + it('replaces reconnect state and applies ordered increments', () => { const model = modelFor() expect(model.getSnapshot()).toMatchObject({ phase: 'pending', state: 'loading' }) diff --git a/packages/api/workspace-controller/tests/remote/workspace.client.ts b/packages/api/workspace-controller/tests/remote/workspace.client.ts index 6857e8e31d..b8951c0c39 100644 --- a/packages/api/workspace-controller/tests/remote/workspace.client.ts +++ b/packages/api/workspace-controller/tests/remote/workspace.client.ts @@ -91,6 +91,7 @@ export function followGenerations(generations: readonly StreamScript[]): StreamS /** Default answers: every command accepted and echoed back as the row or set it names. */ export const workspaceWorld: RemoteTable = { unary: { + 'workspace/initializeDefault': (): RemoteResult => ok({ workspace: workspace('default') }), 'workspace/create': (request: WorkspaceCreateRequest): RemoteResult => ok({ workspace: workspace('created', { path: request.path }), created: true, }), diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index b99406b00c..9829004125 100644 --- a/packages/api/workspace-controller/tests/transport.client.spec.ts +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -318,6 +318,7 @@ describe('WorkspaceController', () => { expect(controller.list).toBe(model) expect(client.ctx.workspaces.list).toBe(model) + await expect(controller.initializeDefault({ directoryName: '默认工作区', title: '默认工作区' }, new AbortController().signal)).resolves.toMatchObject({ workspaceId: 'default' }) await expect(controller.create({ path: '/work/created' })).resolves.toMatchObject({ workspaceId: 'created' }) await expect(controller.rename(wid('one'), 'renamed')).resolves.toMatchObject({ title: 'renamed' }) await expect(controller.insertBefore(wid('one'))).resolves.toBeUndefined() @@ -330,6 +331,7 @@ describe('WorkspaceController', () => { await expect(controller.unpinSession(sid('session'))).resolves.toBeUndefined() await expect(controller.delete(wid('one'))).resolves.toBeUndefined() // Each command crosses the wire as one positional request object. + expect(mock.log.requests('workspace/initializeDefault')).toEqual([{ directoryName: '默认工作区', title: '默认工作区' }]) expect(mock.log.requests('workspace/create')).toEqual([{ path: '/work/created' }]) expect(mock.log.requests('workspace/rename')).toEqual([{ workspaceId: 'one', title: 'renamed' }]) expect(mock.log.requests('workspace/insertBefore')).toEqual([{ workspaceId: 'one' }]) @@ -347,6 +349,9 @@ describe('WorkspaceController', () => { const missingWorkspace = new RemoteError('workspace/not-found', 'gone', { workspaceId: wid('missing') }) const missingSession = new RemoteError('session/not-found', 'missing session', { sessionId: sid('session') }) + mock.remote.workspace.initializeDefault.mockResolvedValueOnce(err(new RemoteError('gateway/internal', 'directory denied', {}))) + await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' })).rejects.toBeInstanceOf(WorkspaceCreateError) + mock.remote.workspace.create.mockResolvedValueOnce(err(new RemoteError('workspace/invalid-path', 'missing path', { path: '/missing' }))) const create = controller.create({ path: '/missing' }) await expect(create).rejects.toBeInstanceOf(WorkspaceCreateError) diff --git a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts index 722b132255..ecaeb9a248 100644 --- a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts +++ b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts @@ -59,7 +59,7 @@ async function harness() { lookups: { configure: () => dispose }, contexts: { configureHost: () => dispose }, } as never) - const controller = new WorkspaceController(ctx) + const controller = new WorkspaceController(ctx, { documentsDirectory: root }) return { controller, ctx, root, storageDomain } } @@ -416,3 +416,49 @@ describe('WorkspaceController follow', () => { await expect(closing).resolves.toEqual({ done: true, value: undefined }) }) }) + +describe('first-use Remote', () => { + it.each(['', ' ', '.', '..', '../outside', 'nested/name', 'nested\\name', 'C:outside', 'name\0', '.. ', 'tail.'])( + 'rejects invalid directory name %j before invoking the registry', async (directoryName) => { + const { controller, ctx } = await harness() + const initialize = vi.spyOn(ctx.workspaceRegistry, 'initializeDefault').mockResolvedValue(undefined) + await expect(controller.initializeDefault({ directoryName, title: 'Default workspace' }, new AbortController().signal)) + .rejects.toMatchObject({ code: 'gateway/bad-request' }) + expect(initialize).not.toHaveBeenCalled() + }, + ) + + it('rejects a blank initial title before invoking the registry', async () => { + const { controller, ctx } = await harness() + const initialize = vi.spyOn(ctx.workspaceRegistry, 'initializeDefault').mockResolvedValue(undefined) + await expect(controller.initializeDefault({ directoryName: 'default-workspace', title: ' ' }, new AbortController().signal)) + .rejects.toMatchObject({ code: 'gateway/bad-request' }) + expect(initialize).not.toHaveBeenCalled() + }) + + it('uses the requested display title independently of the directory name', async () => { + const { controller, root } = await harness() + const result = await controller.initializeDefault({ directoryName: 'default-workspace', title: 'Default workspace' }, new AbortController().signal) + expect(result.workspace).toMatchObject({ path: join(root, 'deepseek-harness', 'default-workspace'), title: 'Default workspace' }) + }) + + it('returns a durable Workspace without allocating a Session', async () => { + const { controller, ctx, root } = await harness() + const signal = new AbortController().signal + const result = await controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, signal) + expect(result.workspace.path).toBe(join(root, 'deepseek-harness', 'Default workspace')) + expect(existsSync(result.workspace.path)).toBe(true) + expect(ctx.sessions.list()).toEqual([]) + expect(await controller.initializeDefault({ directoryName: '默认工作区', title: '默认工作区' }, signal)).toEqual(result) + }) + + it('rejects ineligible first use and propagates preparation failures', async () => { + const { controller, ctx, root } = await harness() + await ctx.workspaceRegistry.create(root) + await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, new AbortController().signal)) + .rejects.toMatchObject({ code: 'gateway/bad-request' }) + vi.spyOn(ctx.workspaceRegistry, 'initializeDefault').mockRejectedValueOnce(new Error('permission denied')) + await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, new AbortController().signal)) + .rejects.toThrow('permission denied') + }) +}) diff --git a/packages/api/workspace-controller/tsconfig.host.json b/packages/api/workspace-controller/tsconfig.host.json index 10c17a0c23..17fda7ee38 100644 --- a/packages/api/workspace-controller/tsconfig.host.json +++ b/packages/api/workspace-controller/tsconfig.host.json @@ -10,15 +10,18 @@ "src/types.ts", "src/commands.ts", "src/directory-picker.ts", - "src/feed.ts" + "src/feed.ts", + "src/default-directory.ts" ], "references": [ { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, { "path": "../../core/session" }, { "path": "../../host/directory-picker" }, { "path": "../../storage/storage-domain" }, { "path": "../../typert/protocol" }, { "path": "../../util/deque" }, + { "path": "../../util/native-command" }, { "path": "../../workspace/workspace" } ] } diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 2ed6513974..7f0a28c544 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/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-agent-preset/README.md -README.md: c7a87da591c1cafc433fbb3bb5fa216d94ab85ef -README.zh.md: a54b47baed132e790fc4f44ad1155f48a19c430b +README.md: 38eba97e2c11ed319a9a5724bece1fbbd1f8095a +README.zh.md: 730c363c0876c6f6bc9ee8565aeb922526ed8f3d diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index c7a87da591..38eba97e2c 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -49,6 +49,8 @@ The settings section writes the Host's existing `agent-presets` namespace throug The chip follows the preset projection of its bound Session while its plugin and Provider generation remain active. Settings synchronization creates the same bound controller when needed, even before the chip mounts. A late projection or another client's preset update refreshes the display without submitting a selection; list notifications do not repeat an in-flight selection. Settings changes wait for that selection and recheck the captured blank Session before applying. A choice staged during a pending request remains available afterward. +After preparing a default Workspace, the first-use draft waits for the staged preset through `conversation/prepare-first-send`. Concurrent `apply()` calls wait for the active selection without repeating it. A refusal keeps the unsent Session draft. + ----- diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index a54b47baed..730c363c08 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -49,6 +49,8 @@ kind: "package-reference" 只要插件及 Provider generation 仍有效,选择芯片就会跟随其绑定 Session 的预设投影。即使选择芯片尚未挂载,设置同步也会按需创建同一个绑定控制器。延后到达的投影或其他客户端的预设更新会刷新显示,而不会提交选择;列表通知不会重复仍在进行中的选择操作。设置修改会等待该操作结束,并在应用之前重新检查已捕获的空白 Session。请求期间暂存的新选择会保留。 +默认工作区准备完成后,首次使用的草稿通过 `conversation/prepare-first-send` 等待暂存的模式选择。并发的 `apply()` 调用等待已有选择操作,不会重复提交。选择被拒绝时保留未发送的 Session 草稿。 + ----- diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index c335518aff..bc8631e577 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -143,6 +143,14 @@ export function apply(ctx: ClientContext): void { // render and simply hides the button while no flow exists. let creatorDraft: (() => void) | undefined ctx.inject(['slots', 'conversation', 'sessions', 'uiWorkspace'], (scope: ClientContext) => { + scope.on('conversation/prepare-first-send', async (sessionId) => { + const binding = scope.sessions.binding(sessionId) + if (binding === undefined) return + const seat = seatFor(binding) + if (staged.id === undefined && !seat.store.getSnapshot().busy) return + const error = await seat.apply() + if (error !== undefined) throw new Error(error) + }) const seatInjected = (sessionId: SessionId | undefined): AgentPresetSeatInjected => { const binding = sessionId === undefined ? undefined : ctx.sessions.binding(sessionId) const seat = binding === undefined ? unboundSeat : seatFor(binding) diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts index 45bd9373e4..4e7488bc3b 100644 --- a/packages/client/ui-agent-preset/src/client/seat-store.ts +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -193,11 +193,18 @@ export class AgentPresetSeatController { * * Called both by `select()` and by whoever observes the current session * changing, because the session may appear either before or after the pick. - * List updates do not repeat a selection while its response is pending. - * @returns this attempt's Host refusal, or undefined when successful or no switch starts. + * Concurrent callers wait for the active selection without repeating it. + * @returns the Host refusal, or undefined when successful or no switch starts. */ async apply(): Promise { - if (this.store.getSnapshot().busy) return + let pending = this.pendingSelection + while (pending !== undefined) { + await pending + const error = this.store.getSnapshot().error + if (error !== null) return error + pending = this.pendingSelection + if (pending === undefined && this.staged.id === undefined) return + } const staged = this.staged.id const session = this.currentSession() if (staged === undefined) { diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index 1e28cb44f4..ea312699ce 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -747,10 +747,12 @@ describe('ui-agent-preset apply', () => { } sessions.notify() const bound = injectSeat(SessionId('s1')) + await ctx.serial('conversation/prepare-first-send', SessionId('s1')) await bound.load() // Connecting a workspace produced the session; the stage reaches it there. await vi.waitFor(() => { expect(calls).toContain('select:minimal') }) + expect(calls.filter(call => call === 'select:minimal')).toHaveLength(1) }) it('applies the stage to a session that records no preset of its own', async () => { @@ -946,6 +948,28 @@ describe('ui-agent-preset apply', () => { }) describe('AgentPresetSeatController reconciliation', () => { + it.each([false, true])('shares the result of an active preset selection (refused: %s)', async (refused) => { + const refusal = { ok: false as const, error: new RemoteError('gateway/internal', 'selection refused', {}) } + const reply = Promise.withResolvers<{ ok: true; value: string } | typeof refusal>() + const select = vi.fn(() => reply.promise) + const controller = new AgentPresetSeatController({ remote: { agentPresets: { select } } } as never, + () => ({ id: SessionId('blank'), blank: true, projectionValues: { agentPreset: 'standard' } })) + const selecting = controller.select('minimal') + let prepared = false + const preparation = controller.apply().finally(() => { prepared = true }) + try { + expect(prepared).toBe(false) + expect(select).toHaveBeenCalledOnce() + reply.resolve(refused ? refusal : { ok: true, value: 'minimal' }) + await expect(preparation).resolves.toBe(refused ? 'selection refused' : undefined) + await selecting + expect(select).toHaveBeenCalledOnce() + } finally { + reply.resolve({ ok: true, value: 'minimal' }) + await Promise.allSettled([selecting, preparation]) + } + }) + it.each([ { refuseFirst: false, refuseLatest: false }, { refuseFirst: false, refuseLatest: true }, @@ -966,16 +990,17 @@ describe('AgentPresetSeatController reconciliation', () => { const first = controller.select('minimal') let settingsSettled = false const settings = controller.syncBlankSession(session.id, 'cordis').finally(() => { settingsSettled = true }) - await controller.apply() + const repeatedFirst = controller.apply() expect(requests.map(request => request.preset)).toEqual(['minimal']) requests[0]!.outcome.resolve(refuseFirst ? refusal : { ok: true, value: 'minimal' }) await first expect(requests.map(request => request.preset)).toEqual(['minimal', 'cordis']) expect(settingsSettled).toBe(false) - await controller.apply() + const repeatedLatest = controller.apply() expect(requests).toHaveLength(2) requests[1]!.outcome.resolve(refuseLatest ? refusal : { ok: true, value: 'cordis' }) await expect(settings).resolves.toBe(refuseLatest ? 'selection refused' : undefined) + await Promise.all([repeatedFirst, repeatedLatest]) expect(controller.store.getSnapshot()).toMatchObject({ current: refuseLatest ? 'standard' : 'cordis', busy: false, error: refuseLatest ? 'selection refused' : null, @@ -992,10 +1017,10 @@ describe('AgentPresetSeatController reconciliation', () => { () => ({ id: SessionId('blank'), blank: true, projectionValues: { agentPreset: 'standard' } })) const first = controller.select('minimal') controller.stage('cordis', true) - await controller.apply() + const repeated = controller.apply() expect(select).toHaveBeenCalledOnce() firstReply.resolve(refused ? refusal : { ok: true, value: 'minimal' }) - await first + await Promise.all([first, repeated]) expect(controller.store.getSnapshot().current).toBe('cordis') await controller.apply() expect(select.mock.calls.map(call => call[1])).toEqual(['minimal', 'cordis']) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 74cd3168df..2002de04c1 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: fef3a6551b517af2ac636687883864525aac70ee -README.zh.md: e9ab08c686420c38c11ca2bede2fd00099a247a5 +README.md: 56b9d06ce5e48ab40691bede2ccb9669e180aa9f +README.zh.md: fd02565698171e0b6db35080181e57b8b5abd42d diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index fef3a6551b..56b9d06ce5 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -50,6 +50,10 @@ Claimed commands retain their identity and highlight when only their arguments a Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and commit navigation. Draft text and attachments move in its synchronous preparation callback only while that request is current; later navigation or owner disposal leaves the original draft intact. +The Client chooses the default directory name and initial title when sending: Chinese uses `默认工作区`, English uses `Default workspace`, and other languages use directory `default-workspace` with title `Default workspace`. The request retains those names during initialization. A retry after failed initialization uses the current language; a successfully initialized Workspace keeps its directory and title across language changes. + +On first use, the same input accepts text before a Workspace is selected. The first send prepares the [default Workspace](../../workspace/workspace/README.md#first-use-workspace), selects its blank Session, transfers the draft, and uses ordinary message submission. Preparation locks further input. Failure preserves the draft and offers Cancel or Choose folder through the existing picker. A manually chosen folder receives the unsent draft; the user sends again. Once created, the Workspace survives a prompt failure. `conversation/prepare-first-send` awaits staged Session settings before automatically submitting the transferred first-use draft; a rejected listener keeps that draft and reports its error. + The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the resident `conversation.header` outside the optional-Session `conversation.content` Component Factory. The header hosts root-scoped navigation without a selected Session; titles, actions, and View tabs remain in its strict Session child. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes strict-Session `views` plus root-scoped `widthControls` local positions. Its default adapter renders the existing `conversation.session` entry, while the main occurrence selects the width handles; an embedded occurrence can replace `views` and omit those handles without rendering the main Header. The shared body and Composer register the queue and Todo docks. The Todo dock uses the shared panel elevation above the composer; its rows use the shared idle, ongoing, and done markers for pending, in-progress, and completed items. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent; the resident container reserves a 40px drag band on macOS desktop and no empty vertical space on Web, Windows, or Linux. Sidebar entries retain their own data and execution prerequisites. A started Session header uses one row when fewer than two Views are available; the tab row reserves space only while rendered. @@ -60,7 +64,7 @@ The shell reads the persisted View preference before rendering when a Session fi Active transcripts in the main occurrence expose content-width drag handles in their uncovered side gutters; embedded occurrences omit them. A View that paints into a gutter raises only its concrete painted element above the handle; transparent full-width wrappers stay below so they do not claim empty gutter. This requires the path between that element and the Conversation body to remain outside an intermediate stacking context; the shipped Chromium behavior is pinned by the browser scenario. Chat applies the rule to table elements, while its column-bounded tool cards need no raise. Wheel motion over a handle still scrolls the transcript, while Ctrl+wheel remains a browser zoom gesture. The sticky composer intentionally owns its full footer band, which is not a resize target; an already-captured drag lifts its indicator until release ([handle styles](src/client/skeleton/ConversationRoot.module.css)). -The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start. +The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. Before a Session exists, the composer holds a local text draft; sending waits for complete Workspace and Session baselines and then prepares the default Workspace. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start. Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index e9ab08c686..fd02565698 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -50,6 +50,10 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, 工作区选择使用 `uiWorkspace.openWorkspace` 准备目标并提交导航。草稿文字和附件仅在该请求仍为当前请求时,通过它的同步准备回调搬移;后续导航或所有者释放会保留原草稿。 +Client 在发送时决定默认目录名和初始标题:中文使用 `默认工作区`,英文使用 `Default workspace`,其他语言使用目录 `default-workspace` 和标题 `Default workspace`。初始化期间,请求保留这些名称。初始化失败后的重试使用当前语言;初始化成功的 Workspace 在切换语言后仍保留原目录和标题。 + +首次使用时,同一个输入框允许在选择 Workspace 前输入文字。首条消息会准备[默认工作区](../../workspace/workspace/README.zh.md#first-use-workspace)、选中其 blank Session、转移草稿,再使用普通消息提交流程。准备期间锁定后续输入。失败时保留草稿,并提供“取消”或通过已有选择器“选择文件夹”。手动选择的文件夹接收未发送草稿,用户再次发送。Workspace 创建成功后,不会因提示词发送失败而消失。`conversation/prepare-first-send` 在自动提交已转移的首次使用草稿前等待暂存的 Session 设置完成;监听器拒绝时保留该草稿并显示错误。 + 本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` 外壳将常驻的 `conversation.header` 放在可选 Session 的 `conversation.content` Component Factory 外。未选中 Session 时,头部仍承载根作用域导航;标题、操作和 View 标签保留在严格 Session 子组件中。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开 strict-Session `views` 与 root-scoped `widthControls` 两个局部位置。默认 adapter 渲染现有 `conversation.session` entry,主 occurrence 选择宽度拖拽条;嵌入式 occurrence 可以替换 `views`、省略拖拽条,且不渲染主 Header。共享正文与 Composer 注册 queue dock 和 Todo dock。Todo dock 在 composer 上方使用共享面板 elevation;其中的行分别以共享 idle、ongoing 与 done 标记表示待处理、进行中与已完成。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载;常驻容器在 macOS 桌面保留 40px 拖拽区域,在 Web、Windows 或 Linux 上不预留空白高度。侧栏各入口仍遵循自身的数据与执行环境要求。 已开始的 Session 在可用 View 少于两个时使用单行标题栏,仅在渲染标签行时保留其高度。 @@ -60,7 +64,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 主 occurrence 的活跃 transcript 只在未被内容覆盖的两侧沟槽中提供正文宽度拖拽条;嵌入式 occurrence 省略这些拖拽条。View 如果绘制进沟槽,只将具体的可见元素提到拖拽条上方;透明的全宽包装层保持在下方,不会占用空白沟槽。该规则要求此元素与 Conversation body 之间不能引入中间堆叠上下文;浏览器场景固定了交付 Chromium 的行为。Chat 将该规则用于表格元素,其限定在阅读列内的工具卡片无需提高层级。指针位于拖拽条上时,滚轮仍会滚动 transcript,Ctrl+滚轮则保留为浏览器缩放手势。粘滞 composer 刻意拥有完整的底部区带,该区域不是宽度调整目标;已捕获的拖拽会将指示线提高到松开为止([拖动手柄样式](src/client/skeleton/ConversationRoot.module.css))。 -常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。 +常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。Session 尚未存在时,composer 保存本地文字草稿;发送会等待 Workspace 与 Session 基线完整加载,然后准备默认工作区。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。 默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 3fef945990..02c1f3a593 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -2,6 +2,7 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { ISessions, SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' +import type { WorkspaceInitializeDefaultRequest } from '@deepseek-ai/dsh-api-workspace-controller/client' import { IconPaperclipOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import { createSnapshotStore, type BoundActions } from '@deepseek-ai/dsh-client-store' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' @@ -17,13 +18,13 @@ import type { ComposerBarInjected, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected, DraftFileUploads, } from './contract/slots.ts' -import type { InputNotice } from './contract/input.ts' import { createConversationStore, readConversationViewPreference } from './stores.ts' import { ConversationController, UnsupportedImageMediaTypeError } from './service.ts' import type { IConversation } from './service.ts' import { ComposerBlockRegistry } from './input/blocks.ts' import type { ComposerBlock } from './contract/composer-blocks.ts' import { InputHub } from './input/hub.ts' +import { FirstDraft } from './input/first-draft.ts' import { ComposerSubmissionPolicy } from './input/submission-policy.ts' import { queueDockEntry } from './queue/QueueDock.tsx' import { EnterBehaviorRow } from './settings/EnterBehaviorRow.tsx' @@ -64,19 +65,10 @@ export const Config: z = z.object({ // Stable no-session sources keep the renderer's observable-hook cache and // hook order unchanged across current-Session transitions. -const ABSENT_NOTICES = { - getSnapshot: (): InputNotice | null => null, - subscribe: () => () => {}, -} const ABSENT_BLOCK = { getSnapshot: (): ComposerBlock | undefined => undefined, subscribe: () => () => {}, } -const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map() -const ABSENT_LEXICON = { - getSnapshot: () => EMPTY_LEXICON, - subscribe: () => () => {}, -} const ABSENT_MENU_LAUNCHER = { getSnapshot: (): string | null => null, subscribe: () => () => {}, @@ -87,12 +79,19 @@ const ABSENT_FILE_UPLOADS = { subscribe: () => () => {}, } +// ui-workspace consumes Conversation slots, so this reverse runtime dependency +// uses a local method projection to keep the TypeScript project graph acyclic. interface WorkspaceNavigation { openSession(sessionId: SessionId): void openWorkspace( workspaceId: Parameters[0], beforeOpen: (sessionId: SessionId) => void, ): Promise + openDefaultWorkspace( + request: WorkspaceInitializeDefaultRequest, + beforeOpen: (sessionId: SessionId) => void, + signal: AbortSignal, + ): Promise } /** Action registration used by the composer without importing its command-UI consumer. */ @@ -206,6 +205,17 @@ export function apply(ctx: Context, config: Config = Config({})): void { }, 'ui-conversation: View selection') const inputHub = new InputHub(ctx, t) + const firstDraft = new FirstDraft(ctx, { + open: (beforeOpen, signal) => { + const language = ctx.locale.getSnapshot().active.toLowerCase().split('-')[0] + const title = (language === 'zh' ? zh : en)['defaultWorkspace.title'] + return workspaceNavigation.openDefaultWorkspace({ + directoryName: language === 'zh' || language === 'en' ? title : 'default-workspace', + title, + }, beforeOpen, signal) + }, + shell: id => inputHub.shell(id), + }) const composerBlocks = new ComposerBlockRegistry() ctx.inject(['commandUi'], (scope) => { @@ -266,8 +276,11 @@ export function apply(ctx: Context, config: Config = Config({})): void { inject: (sessionId: SessionId | undefined): ConversationInjected => ({ hooks: { composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId), + firstDraft: firstDraft.state, }, + dismissDefaultFailure: () => { firstDraft.dismiss() }, selectWorkspace: workspaceId => workspaceNavigation.openWorkspace(workspaceId, (nextId) => { + if (sessionId === undefined) firstDraft.transfer(nextId) if (sessionId !== undefined && nextId !== sessionId) { const from = inputHub.shell(sessionId) const draft = from.snapshot.draft @@ -373,7 +386,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { inject: (sessionId: SessionId | undefined): ComposerBarInjected => { if (sessionId === undefined) { return { - keyboard: undefined, + keyboard: firstDraft.shell, addFiles: undefined, removeAttachment: undefined, resolveDraftAttachments: undefined, @@ -381,10 +394,11 @@ export function apply(ctx: Context, config: Config = Config({})): void { toggleCommandMenu: undefined, stop: undefined, hooks: { + composerInput: firstDraft.shell.state, busyEnter: submissionPolicy.busyEnter, fileUploads: ABSENT_FILE_UPLOADS, - notices: ABSENT_NOTICES, - lexicon: ABSENT_LEXICON, + notices: firstDraft.shell.notices, + lexicon: firstDraft.shell.lexicon, menuLauncher: ABSENT_MENU_LAUNCHER, }, } @@ -433,6 +447,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { }) }, hooks: { + composerInput: shell.state, busyEnter: submissionPolicy.busyEnter, fileUploads: conversation.fileUploads, notices: shell.notices, diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index b4cd6b9bdf..be338eb3df 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -34,6 +34,12 @@ export interface SubmitOutcome { readonly text?: string } +/** State of first-use Workspace preparation. */ +export interface FirstDraftState { + readonly busy: boolean + readonly failed: boolean +} + /** Command-mode credential supplied by one input-trigger source. */ export interface CommandClaim { /** Catalog command name without the leading slash (the key of per-command composer copy such as `hint.*`). */ diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 023babf26e..a0d9a6d592 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -16,7 +16,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import type { ComposerBlock } from './composer-blocks.ts' -import type { DraftAttachmentId, InputActions, InputNotice, InputState } from './input.ts' +import type { DraftAttachmentId, FirstDraftState, InputActions, InputNotice, InputState } from './input.ts' import type { ComposerKeyboard, EditSelection } from './draft-editor.ts' import type { createConversationStore } from '../stores.ts' import type { BusyEnterBehavior } from './composer-submission.ts' @@ -319,8 +319,13 @@ export type ConvViewProps = PropsRuntime<'conversation.view'> export interface ConversationInjected { /** Connect and open a blank Session in the selected Workspace. */ selectWorkspace: (workspaceId: WorkspaceId) => Promise + /** Dismiss default Workspace creation failure without discarding input. */ + dismissDefaultFailure: () => void /** Session-addressed composer block source, or the stable absent source. */ - hooks: { composerBlock: ObservableSnapshot } + hooks: { + composerBlock: ObservableSnapshot + firstDraft: ObservableSnapshot + } } /** Business callbacks injected into the strict Session body. */ @@ -365,7 +370,7 @@ export interface ComposerBarOwnerProps { /** Package-private operations injected into the resident composer bar. */ export interface ComposerBarInjected { - keyboard: ComposerKeyboard | undefined + keyboard: ComposerKeyboard addFiles: ((files: readonly File[]) => string | null) | undefined removeAttachment: ((id: DraftAttachmentId) => void) | undefined resolveDraftAttachments: ((ids: readonly DraftAttachmentId[]) => readonly ComposerAttachment[]) | undefined @@ -374,6 +379,8 @@ export interface ComposerBarInjected { toggleCommandMenu: ((selection: EditSelection) => void) | undefined stop: (() => void) | undefined hooks: { + /** Resident editor state, including input before a Session exists. */ + composerInput: ObservableSnapshot /** * Live busy-state submission preference: the delivery mode plain Enter * and the primary Send button use while the addressed agent is busy. diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 73ddc6d7d3..468fc9c198 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -72,6 +72,14 @@ export type { ArbitrateKey, ArbitrateOutcome, ReferenceInsert, TokenSpan } from export type { ComposerBlock, ComposerBlocks } from './contract/composer-blocks.ts' declare module '@deepseek-ai/cordis' { + interface Events { + /** + * Finish staged settings before automatically submitting the first-use draft to its new Session. + * @mode serial + * @param sessionId - destination Session. + */ + 'conversation/prepare-first-send'(sessionId: import('@deepseek-ai/dsh-session/types').SessionId): void | Promise + } interface Context { /** Scope-addressed Conversation actions and per-Session input registry. */ conversation: import('./service.ts').IConversation diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 4f4d0aa300..77bff28708 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -5,8 +5,8 @@ * scoped-event application verbs, the submit transaction plumbing * (adjudicate via the session's InputTriggerController; claim.submit; default * sink), the notice channel, and the draft persistence mirror. - * Package-private; the hub alone constructs it and wires the scoped event - * listeners onto it. + * Package-private; InputHub binds Session events, while FirstDraft owns the + * local editor used before a Session exists. */ import type { Context } from '@deepseek-ai/cordis' import type { InboxState } from '@deepseek-ai/dsh-agent/types' diff --git a/packages/client/ui-conversation/src/client/input/first-draft.ts b/packages/client/ui-conversation/src/client/input/first-draft.ts new file mode 100644 index 0000000000..9e1c486eae --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/first-draft.ts @@ -0,0 +1,79 @@ +/** Local text draft retained until the first Workspace and Session can accept it. */ +import type { Context } from '@deepseek-ai/cordis' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { FirstDraftState, SubmitOutcome } from '../contract/input.ts' +import type { InputSubmitMode } from '../contract/composer-submission.ts' +import { SessionInputShell } from './facade.ts' + +/** First-use navigation and the resident destination input. */ +interface FirstDraftDeps { + open(beforeOpen: (id: SessionId) => void, signal: AbortSignal): Promise + shell(id: SessionId): SessionInputShell +} + +/** Owns a root-lifetime text editor without allocating a Host Session. */ +export class FirstDraft { + /** Preparation progress and the recoverable directory failure. */ + readonly state = createSnapshotStore({ busy: false, failed: false }) + /** Local editor that stays independent of Host Session allocation. */ + readonly shell: SessionInputShell + private pending: { text: string; transferred: boolean } | undefined + + /** @param ctx - Client plugin lifetime. @param deps - Workspace navigation and Session input access. */ + constructor(private readonly ctx: Context, private readonly deps: FirstDraftDeps) { + this.shell = new SessionInputShell({ + actx: ctx, + defaultSink: (text, _attachments, mode, signal) => this.send(text, mode, signal), + commandAttachments: { + serialize: () => Promise.resolve([]), + release: () => {}, + unsupportedNotice: () => '', + }, + }) + ctx.effect(() => () => { this.shell.dispose() }, 'conversation.input: first-use draft') + } + + /** Dismiss the preparation failure while retaining the draft. */ + dismiss(): void { + this.state.set({ ...this.state.getSnapshot(), failed: false }) + } + + /** + * Move the unsent text to a selected Session. + * @param id - retained destination Session receiving the unsent text. + */ + transfer(id: SessionId): void { + const text = this.pending?.text ?? this.shell.snapshot.draft + if (text !== '') this.deps.shell(id).setDraft(text) + if (this.pending !== undefined) this.pending.transferred = true + this.shell.setDraft('') + this.dismiss() + } + + private async send(text: string, mode: InputSubmitMode, signal: AbortSignal): Promise { + if (this.pending !== undefined) return { kind: 'error' } + const pending = { text, transferred: false } + this.pending = pending + this.state.set({ busy: true, failed: false }) + let target: SessionInputShell | undefined + try { + const id = await this.deps.open((nextId) => { this.transfer(nextId) }, signal) + if (id === undefined) return { kind: pending.transferred ? 'success' : 'error' } + target = this.deps.shell(id) + await this.ctx.serial('conversation/prepare-first-send', id) + signal.throwIfAborted() + target.submit(mode) + return { kind: 'success' } + } catch (error) { + if (!signal.aborted) { + if (target !== undefined) target.notify('error', error instanceof Error ? error.message : String(error)) + else if (!pending.transferred) this.state.set({ busy: true, failed: true }) + } + return { kind: pending.transferred ? 'success' : 'error' } + } finally { + this.pending = undefined + this.state.set({ ...this.state.getSnapshot(), busy: false }) + } + } +} diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 464ff55e17..c66fe10a8a 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -9,6 +9,10 @@ const PLAN_NEXT_ACTION_EN = 'describe your task to generate plan' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'defaultWorkspace.failed': '无法创建默认工作区', + 'defaultWorkspace.title': '默认工作区', + 'defaultWorkspace.retry': '请选择一个文件夹后重试', + 'defaultWorkspace.choose': '选择文件夹', 'hint.plan': PLAN_NEXT_ACTION_ZH, 'hint.goal': '输入目标,智能体将持续执行', 'hint.goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除', @@ -360,6 +364,10 @@ export type ConversationKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { + 'defaultWorkspace.failed': 'Unable to create default workspace', + 'defaultWorkspace.title': 'Default workspace', + 'defaultWorkspace.retry': 'Please choose a folder and try again', + 'defaultWorkspace.choose': 'Choose folder', 'hint.plan': PLAN_NEXT_ACTION_EN, 'hint.goal': 'describe the objective for a long-running task', 'hint.goal.active': 'goal active — edit / pause / resume / clear', diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx index adace1c113..bf6456291f 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' +import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import type { ConversationContentProps, ConversationViewsProps, InputZone } from '../contract/slots.ts' import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx' @@ -22,7 +23,7 @@ export function ConversationContent(props: ConversationContentProps) { const { sessionId, phase, hero, useSession, useSessions, useSessionStatus, useWorkspaces, useInput, useComposerBlock, renderSlot, renderSlotChain, - selectWorkspace, t, useFactorySlot, + selectWorkspace, dismissDefaultFailure, useFirstDraft, t, useFactorySlot, } = props const session = useSession(snapshot => snapshot) const Views = useFactorySlot('views', ConversationSessionView) @@ -33,6 +34,8 @@ export function ConversationContent(props: ConversationContentProps) { const inputState = useInput(s => s) const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd) const workspaces = useWorkspaces(s => s) + const sessionsReady = useSessions(s => s.phase === 'ready') + const firstDraft = useFirstDraft(s => s) // A plugin this package cannot import (ui-model-selection) says this session cannot // send; its reason is already localized by whoever raised it. const composerBlock = useComposerBlock(block => block) @@ -127,15 +130,13 @@ export function ConversationContent(props: ConversationContentProps) { ) - // The placeholder chip ("Choose workspace") and the Workspace-trigger input travel - // together: no workspace picked yet (cold start, no session at all), or a - // blank session whose workspace vanished (deleted from the sidebar). The - // bar is ONE session-maybe slot rendered unconditionally — inert is a prop, - // not a different tree, so the textarea DOM survives the transition. - const inert = sessionId === undefined || (hero && chipTitle === undefined) - // A raised block is the same inert posture with the blocker's own reason: - // one disabled textarea, never a second tree. The no-workspace state wins - // when both hold — picking a workspace is the earlier prerequisite. + // First-use sending waits for both baselines. A blank Session whose + // Workspace disappeared still requires explicit directory selection. + const needsWorkspace = sessionId !== undefined && hero && chipTitle === undefined + const inert = firstDraft.busy || (sessionId === undefined + ? workspaces.phase !== 'ready' || !sessionsReady + : needsWorkspace) + // Owner availability takes precedence over a Session-specific model block. const blocked = !inert && composerBlock !== undefined const inputBar = renderSlot('conversation.composer.bar', { variant: hero ? 'hero' : 'composer', @@ -144,7 +145,7 @@ export function ConversationContent(props: ConversationContentProps) { disabled: true, placeholder: t('placeholder.workspace'), workspacePickerOpen: pickerOpen, - onRequestWorkspace: () => { setPickerOpen(true) }, + ...(needsWorkspace ? { onRequestWorkspace: () => { setPickerOpen(true) } } : {}), } : blocked // `blocked`, not `disabled`: the bar refuses input either way, but a @@ -191,6 +192,23 @@ export function ConversationContent(props: ConversationContentProps) { {composerSeat} + + + + + )} + > + {t('defaultWorkspace.retry')} + ) } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index c57fe69936..f66ee7a96a 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -1,6 +1,5 @@ -// Resident conversation skeleton. Hero chrome, composer positioning, the -// chain, AND the composer bar (session-maybe slot) stay mounted across -// no-session/session transitions — the bar renders inert via owner props. +// Resident conversation shell keeps the composer mounted across local-draft, +// blank-Session, and active-conversation views. import type { ConversationSlotProps } from '../contract/slots.ts' import { ConversationMainPanel } from './ConversationMainPanel.tsx' diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 6a4ea12740..5852e4b5c4 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -1,16 +1,12 @@ /** The default composer body: the 'conversation.composer.bar' slot entry. - * Machine state arrives through the standard provide channel - * (useInput + inputActions); the keyboard/DOM command face and stop arrive - * through this entry's own inject, whose hooks compartment binds - * useNotices/useLexicon; layout-phase inputs (variant and placeholder) ride - * the owner props. Session facts - * (running/removed/promptError) are self-selected via useSession. + * The injected input state and keyboard address the current Session or the + * first-use local draft. Standard Session props supply attachment actions; + * owner props control layout and availability. * * The text surface is the shell-owned Lexical editor bound here through * ComposerContentEditable; chips render as decorator portals, and the * keymap registers submit/menu/paste gestures on the editor command layer. - * The no-session state renders the SAME div inert as the Workspace-picker - * trigger instead of a parallel tree. + * The contenteditable remains mounted when the first Session arrives. */ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -42,7 +38,7 @@ import css from './InputBar.module.css' export type InputBarProps = ComposerBarProps export const InputBar = memo(function InputBar({ - useSession, useInput, inputActions, keyboard, addFiles, removeAttachment, resolveDraftAttachments, + useSession, useComposerInput, inputActions, keyboard, addFiles, removeAttachment, resolveDraftAttachments, retryFileUpload, toggleCommandMenu, stop, t, renderSlot, useBusyEnter, useFileUploads, useNotices, useLexicon, useMenuLauncher, @@ -50,7 +46,7 @@ export const InputBar = memo(function InputBar({ workspacePickerOpen = false, onRequestWorkspace, placeholder, accessory, }: InputBarProps) { - const input = useInput(s => s) + const input = useComposerInput(s => s) const notice = useNotices(s => s) const busyEnter = useBusyEnter(s => s) void useLexicon // hook seat stays bound by the inject compartment; text-ref decoration rides the shell's editor transforms @@ -64,14 +60,11 @@ export const InputBar = memo(function InputBar({ const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active)) // Absent (undefined: no frame yet) and cleared (null) both mean no goal. const hasGoal = useProjection('goal', goal => goal != null) - // Session-maybe: the machine faces are absent together while no session is - // current; the bar renders the same DOM inert instead of a parallel tree. - const live = input !== undefined && keyboard !== undefined && inputActions !== undefined - const draft = input?.draft ?? '' - const editor = keyboard?.editor ?? null + const draft = input.draft + const editor = keyboard.editor const attachments = useMemo( - () => input === undefined || resolveDraftAttachments === undefined ? [] : resolveDraftAttachments(input.attachmentIds), - [resolveDraftAttachments, input?.attachmentIds], + () => resolveDraftAttachments === undefined ? [] : resolveDraftAttachments(input.attachmentIds), + [resolveDraftAttachments, input.attachmentIds], ) const empty = draft.trim() === '' && attachments.length === 0 const uploads = useFileUploads(snapshot => snapshot) @@ -93,8 +86,7 @@ export const InputBar = memo(function InputBar({ // The deployment's image-intake limits (absent while no attachment service // is composed — the pre-check below then defers entirely to the host). const imageLimits = useProjection('imageLimits') - // Prompt failures are ordinary failures (no create/attach transaction exists - // anymore): the toast announces promptError, the draft stays in the machine, + // The toast announces promptError, the draft stays in the machine, // and the user resubmits. A remount over a session whose machine still holds // an unresolved promptError deliberately re-announces it once — the failure // is still pending, and a transient banner is its only surface. Attachment @@ -122,35 +114,30 @@ export const InputBar = memo(function InputBar({ // but its independent Stop below stays available while it runs. const continuable = subagent?.address.mode === 'continuable' const parentOffline = continuable && subagent.parentAvailable !== true - // Running input stays free; locked = session removed, the - // inert no-workspace state, the machine faces absent (no session), or a - // parent-offline continuable child. An owner block also disables input; - // adjudicating and submitting render read-only so the draft stays visible. - const disabled = removed || inert || !live || blocked !== undefined || parentOffline + // Preparation, removed Sessions, and unavailable parents lock editing. + // Adjudicating and submitting keep the draft visible and read-only. + const disabled = removed || inert || blocked !== undefined || parentOffline const locked = disabled // The model seat is the ONE control a block leaves live: every block this // contract has is cleared by choosing a model, so locking it too would leave // the composer asking for the only thing it prevents. The other reasons to // be disabled do lock it — there is no session to choose a model for. - const modelSeatLocked = removed || inert || !live - const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting' - // The no-workspace surface remains the resident DOM node but acts as the - // existing picker trigger. Message controls stay locked until a Session - // exists; the trigger itself is read-only rather than disabled so pointer - // and keyboard users can reach the recovery action. + const modelSeatLocked = removed || inert + const machineBusy = input.phase === 'adjudicating' || input.phase === 'submitting' + // A blank Session whose Workspace was deleted offers directory selection + // through the same resident input node. const workspaceTrigger = inert && !removed && onRequestWorkspace !== undefined const editorDisabled = removed || (locked && !workspaceTrigger) - const editable = live && !locked && !machineBusy + const editable = !locked && !machineBusy const steeringAvailable = subagent === null || subagent.address.mode === 'continuable' const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && steeringAvailable && input.queue.length > 0 useEffect(() => { - if (input === undefined || inputActions === undefined) return - if (attachments.length !== input.attachmentIds.length) { + if (inputActions !== undefined && attachments.length !== input.attachmentIds.length) { inputActions.pruneAttachments(attachments.map(attachment => attachment.id)) } - }, [attachments, input?.attachmentIds, inputActions]) + }, [attachments, input.attachmentIds, inputActions]) // Scroll the draft scrollport the minimum that brings the selection focus // into view — the browser's own behavior for typing, performed for the @@ -167,7 +154,7 @@ export const InputBar = memo(function InputBar({ // is ours to perform — switching to a longer draft otherwise leaves the // caret (restored at the draft's end) off screen. useEffect(() => { - if (locked || editor === null) return + if (locked) return focusDraftEditor(editor, revealSelection) }, [locked, sessionId, editor]) @@ -244,12 +231,10 @@ export const InputBar = memo(function InputBar({ } useEffect(() => { - if (keyboard === undefined) return return installDraftFilePicker(keyboard, gate, fileInputRef) }, [keyboard]) useEffect(() => { - if (editor === null || keyboard === undefined) return return installDraftKeymap(editor, keyboard, gate) }, [editor, keyboard]) @@ -262,17 +247,16 @@ export const InputBar = memo(function InputBar({ } const onToggleCommandMenu = (): void => { - if (keyboard === undefined) return // The menu is a combobox over the editor, so the keyboard has to be there // before the launcher opens it: activating the button from the keyboard // leaves focus on the button, and restoring it afterwards would re-track an // empty draft and close the menu again. - if (editor !== null) focusDraftEditor(editor, revealSelection) + focusDraftEditor(editor, revealSelection) toggleCommandMenu?.(keyboard.caretSpan()) } - // The no-session Workspace trigger: the resident editable div acts as the - // picker trigger for keyboard users (no editor is bound in this state). + // When a blank Session loses its Workspace, the resident editable div acts as the + // picker trigger for keyboard users. const onWorkspaceKeyDown = (e: KeyboardEvent): void => { if (!workspaceTrigger) return if (e.key === 'Enter' || e.key === ' ') { @@ -295,7 +279,7 @@ export const InputBar = memo(function InputBar({ const primaryDisabled = primaryStops ? stop === undefined : empty || disabled || machineBusy || uploadsPending const interruptible = running && continuable const primarySubmitMode = resolveSubmitMode(busyEnter, running, 'enter', steeringAvailable) - const plainMessageDraft = !empty && input?.phase === 'plain' && !draft.trimStart().startsWith('/') + const plainMessageDraft = !empty && input.phase === 'plain' && !draft.trimStart().startsWith('/') const primaryLabel = primaryStops ? t('input.stop') : running && steeringAvailable && !disabled && !uploadsPending && plainMessageDraft @@ -306,7 +290,6 @@ export const InputBar = memo(function InputBar({ stop?.() return } - if (keyboard === undefined) return // absent machine: the button is disabled /* v8 ignore next -- defensive: the primary button is disabled for empty, disabled, and pending-upload states. */ if (!empty && !disabled && !machineBusy && !uploadsPending) keyboard.submit(primarySubmitMode) } @@ -314,7 +297,7 @@ export const InputBar = memo(function InputBar({ // Claim ghost hint: rendered by CSS as generated content after the last // paragraph while the claim's args are blank (a hint implies a single-line // token draft). The translated per-command hint wins over the claim's own. - const claimActive = (input?.phase === 'claimed' || input?.phase === 'submitting') + const claimActive = (input.phase === 'claimed' || input.phase === 'submitting') && input.claim !== undefined && draft.startsWith(input.claim.token) const rawHint = claimActive && input.claim.hint !== undefined && draft.slice(input.claim.token.length).trim() === '' @@ -322,7 +305,7 @@ export const InputBar = memo(function InputBar({ : null const hint = ((): string | null => { if (rawHint === null) return null - const commandName = input?.claim?.name ?? '' + const commandName = input.claim?.name ?? '' const hintKey = `hint.${commandName === 'goal' && hasGoal ? 'goal.active' : commandName}` // Dynamic lookup by claimed command name: unknown commands miss the // dictionary and keep the machine's own hint, so the call is wide. @@ -396,7 +379,7 @@ export const InputBar = memo(function InputBar({ scrollRef={scrollRef} editable={editable} editorDisabled={editorDisabled} - phase={input?.phase ?? 'inert'} + phase={input.phase} placeholderText={placeholderText} ariaLabel={workspaceTrigger ? t('hero.chooseWorkspace') : placeholderText} workspaceTrigger={workspaceTrigger} @@ -433,12 +416,12 @@ export const InputBar = memo(function InputBar({ {sessionId === undefined ? null : renderSlot('conversation.input.permission', { locked })} {sessionId === undefined ? null : renderSlot('conversation.input.plan', { locked })} - {input === undefined || sessionId === undefined + {sessionId === undefined ? null : renderSlot('conversation.input.left', {})}
- {input === undefined || sessionId === undefined + {sessionId === undefined ? null : renderSlot('conversation.input.right', {})} {sessionId === undefined ? null : renderSlot('conversation.input.model', { locked: modelSeatLocked })} @@ -482,7 +465,7 @@ export const InputBar = memo(function InputBar({
- {variant === 'composer' && input !== undefined && sessionId !== undefined + {variant === 'composer' && sessionId !== undefined ? renderSlot('conversation.composer.dock', {}) : null} diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 6c12f28cfb..abc74918d5 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -2,6 +2,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { describe, expect, it, onTestFinished, vi } from 'vitest' import type { CommandContribution, CommandUiContract } from '@deepseek-ai/dsh-client-ui-commands/client' +import type { UiWorkspace } from '@deepseek-ai/dsh-client-ui-workspace/client' import type { ISession, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' @@ -73,12 +74,14 @@ async function bench() { opened(id) } const openSession = vi.fn((id: SessionId) => { replaceMain(id) }) + const openDefaultWorkspace = vi.fn(async () => undefined) runtime.ctx.provide('uiWorkspace', { openWorkspace: async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { const id = await connectWorkspace() replaceMain(id, beforeOpen) }, openSession, + openDefaultWorkspace, } as never) const sessionFake = sessionFakeFor() await runtime.sessions.add({ @@ -136,10 +139,54 @@ async function bench() { return { runtime, feature, slots: runtime.slots, entryOf, conversationApi, headerApi, residentApi, composerApi, inputApi, viewSource, sessionFake, connectWorkspace, rootUpload, uploads, rootReference, references, opened, + locale, openDefaultWorkspace, } } describe('Conversation inject API', () => { + it.each([ + ['zh', '默认工作区', '默认工作区'], + ['en', 'Default workspace', 'Default workspace'], + ['ja', 'default-workspace', 'Default workspace'], + ])('resolves first-use names in the Client using the send-time locale %s', async (language, directoryName, title) => { + const b = await bench() + if (language === 'ja') b.locale.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' }) + const composer = b.composerApi(undefined) + composer.keyboard.paste('first message') + b.locale.setLocale(language) + composer.keyboard.submit('steer') + await vi.waitFor(() => { + expect(b.openDefaultWorkspace).toHaveBeenCalledWith({ directoryName, title }, expect.any(Function), expect.any(AbortSignal)) + expect(b.residentApi(undefined).hooks.firstDraft.getSnapshot().busy).toBe(false) + }) + }) + + it('keeps in-flight names fixed and resolves the new locale when retrying a failed first send', async () => { + const b = await bench() + const pending = Promise.withResolvers() + b.openDefaultWorkspace.mockReturnValueOnce(pending.promise) + const composer = b.composerApi(undefined) + b.locale.setLocale('en') + composer.keyboard.paste('keep this draft') + composer.keyboard.submit('steer') + try { + await vi.waitFor(() => { expect(b.openDefaultWorkspace).toHaveBeenCalledOnce() }) + b.locale.setLocale('zh') + expect(b.openDefaultWorkspace.mock.calls[0]?.[0]).toEqual({ directoryName: 'Default workspace', title: 'Default workspace' }) + pending.reject(new Error('directory unavailable')) + await vi.waitFor(() => { expect(b.residentApi(undefined).hooks.firstDraft.getSnapshot().busy).toBe(false) }) + expect(composer.hooks.composerInput.getSnapshot().draft).toBe('keep this draft') + composer.keyboard.submit('steer') + await vi.waitFor(() => { + expect(b.openDefaultWorkspace).toHaveBeenLastCalledWith({ directoryName: '默认工作区', title: '默认工作区' }, expect.any(Function), expect.any(AbortSignal)) + expect(b.residentApi(undefined).hooks.firstDraft.getSnapshot().busy).toBe(false) + }) + } finally { + pending.resolve(undefined) + await vi.waitFor(() => { expect(b.residentApi(undefined).hooks.firstDraft.getSnapshot().busy).toBe(false) }) + } + }) + it('owns the File action, reads its mounted composer availability, and unregisters on disposal', async () => { const b = await bench() onTestFinished(() => b.runtime.dispose()) @@ -159,7 +206,7 @@ describe('Conversation inject API', () => { expect(file.available({ sessionId: 'missing' as SessionId })).toBe(false) if (file.ui.kind !== 'action') throw new Error('File must be an action') file.ui.run({ sessionId: 'missing' as SessionId }) - const keyboard = b.composerApi(ROOT).keyboard! + const keyboard = b.composerApi(ROOT).keyboard const open = vi.fn() let available = true const unbind = keyboard.bindFilePicker({ open, available: () => available }) @@ -386,7 +433,8 @@ describe('Conversation inject API', () => { expect(() => { injectBar('ghost' as SessionId).stop!() }).toThrow(/resolved no binding/) const absent = injectBar(undefined) - expect(absent.keyboard).toBeUndefined() + expect(absent.keyboard).toBeDefined() + expect(absent.hooks.composerInput.getSnapshot().draft).toBe('') expect(absent.toggleCommandMenu).toBeUndefined() expect(absent.stop).toBeUndefined() expect(absent.hooks.notices.getSnapshot()).toBeNull() diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx index 84e5ba28a1..51513cb28e 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx @@ -100,8 +100,10 @@ async function bench(opts?: { blank?: boolean }) { } describe('resident composer', () => { - it('renders the locked view state while no session exists at all', async () => { + it('accepts a local draft before any Session exists and keeps the Workspace chip available', async () => { const runtime = await SlotTestRuntime.create() + runtime.sessions.list.set({ ...runtime.sessions.list.getSnapshot(), phase: 'pending' }) + await runtime.workspaces.update((draft) => { draft.phase = 'pending' }) provideWorkspaceNavigation(runtime) runtime.ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(runtime.ctx) @@ -113,15 +115,21 @@ describe('resident composer', () => { const view = runtime.renderRoot() const textarea = view.container.querySelector('[data-composer-input]') expect(textarea).not.toBeNull() + expect(textarea!.getAttribute('contenteditable')).toBe('false') + await runtime.workspaces.update((draft) => { draft.phase = 'ready' }) + expect(textarea!.getAttribute('contenteditable')).toBe('false') + await act(async () => { + runtime.sessions.list.set({ ...runtime.sessions.list.getSnapshot(), phase: 'ready' }) + }) expect(textarea!.getAttribute('aria-disabled')).not.toBe('true') - expect(textarea!.getAttribute('contenteditable')).not.toBe('true') - expect(textarea!.getAttribute('aria-haspopup')).toBe('menu') + expect(textarea!.getAttribute('contenteditable')).toBe('true') + expect(textarea!.getAttribute('aria-haspopup')).toBeNull() expect(view.getByTestId('workspace-probe').textContent).toBe('false:0') - fireEvent.click(textarea!) - expect(view.getByTestId('workspace-probe').textContent).toBe('true:0') - expect(textarea!.getAttribute('aria-expanded')).toBe('true') fireEvent.click(view.getByRole('button', { name: '选择工作区' })) - fireEvent.keyDown(textarea!, { key: 'Enter' }) + expect(view.getByTestId('workspace-probe').textContent).toBe('true:0') + expect(textarea!.getAttribute('aria-expanded')).toBeNull() + fireEvent.click(view.getByRole('button', { name: '选择工作区' })) + fireEvent.click(view.getByRole('button', { name: '选择工作区' })) expect(view.getByTestId('workspace-probe').textContent).toBe('true:0') expect(view.getByRole('button', { name: '选择工作区' })).toBeTruthy() await runtime.dispose() @@ -149,7 +157,7 @@ describe('resident composer', () => { const workspaceChip = view.getByRole('button', { name: '选择工作区' }) const workspaceProbe = view.getByTestId('workspace-probe') expect(textarea.getAttribute('aria-disabled')).not.toBe('true') - expect(textarea.getAttribute('contenteditable')).not.toBe('true') + expect(textarea.getAttribute('contenteditable')).toBe('true') fireEvent.click(workspaceChip) fireEvent.click(workspaceProbe) diff --git a/packages/client/ui-conversation/tests/first-draft.client.spec.ts b/packages/client/ui-conversation/tests/first-draft.client.spec.ts new file mode 100644 index 0000000000..3d223a38fe --- /dev/null +++ b/packages/client/ui-conversation/tests/first-draft.client.spec.ts @@ -0,0 +1,141 @@ +import { Context } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { FirstDraft } from '../src/client/input/first-draft.ts' +import { SessionInputShell } from '../src/client/input/facade.ts' +import type { SubmitOutcome } from '../src/client/contract/input.ts' + +const contexts: Context[] = [] +afterEach(async () => { + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() +}) + +function harness() { + const ctx = new Context() + contexts.push(ctx) + const opened = Promise.withResolvers() + ctx.effect(() => () => { opened.resolve(undefined) }) + const submitted = vi.fn(async (): Promise => ({ kind: 'success' })) + const target = new SessionInputShell({ + actx: ctx, + defaultSink: submitted, + commandAttachments: { serialize: async () => [], release: () => {}, unsupportedNotice: () => '' }, + }) + ctx.effect(() => () => { target.dispose() }) + let beforeOpen: ((id: SessionId) => void) | undefined + const open = vi.fn((prepare: (id: SessionId) => void) => { + beforeOpen = prepare + return opened.promise + }) + const first = new FirstDraft(ctx, { open, shell: () => target }) + return { + ctx, first, target, submitted, open, opened, + complete: () => { + const id = SessionId('default') + beforeOpen!(id) + opened.resolve(id) + }, + } +} + +describe('first message draft', () => { + it('retains a local draft without opening a Session until send', async () => { + const h = harness() + h.first.shell.setDraft('first message') + expect(h.open).not.toHaveBeenCalled() + h.first.shell.submit('steer') + expect(h.open).toHaveBeenCalledTimes(1) + expect(h.first.state.getSnapshot().busy).toBe(true) + h.first.shell.submit('steer') + expect(h.open).toHaveBeenCalledTimes(1) + h.complete() + await vi.waitFor(() => { expect(h.submitted).toHaveBeenCalledTimes(1) }) + expect(h.submitted.mock.calls[0]).toEqual(['first message', [], 'steer', expect.any(AbortSignal)]) + expect(h.first.state.getSnapshot()).toEqual({ busy: false, failed: false }) + }) + + it.each([false, true])('waits for first-use settings and keeps the Session draft on refusal (refused: %s)', async (refused) => { + const h = harness() + const settings = Promise.withResolvers() + const started = Promise.withResolvers() + h.ctx.on('conversation/prepare-first-send', (id) => { + started.resolve(id) + return settings.promise + }) + try { + h.first.shell.setDraft('use my selected mode') + h.first.shell.submit() + h.complete() + expect(await started.promise).toBe(SessionId('default')) + expect(h.submitted).not.toHaveBeenCalled() + if (refused) settings.reject(new Error('preset refused')) + else settings.resolve(undefined) + await vi.waitFor(() => { expect(h.first.state.getSnapshot().busy).toBe(false) }) + expect(h.first.state.getSnapshot().failed).toBe(false) + if (refused) { + expect(h.submitted).not.toHaveBeenCalled() + expect(h.target.snapshot.draft).toBe('use my selected mode') + expect(h.target.notices.getSnapshot()?.text).toBe('preset refused') + } else { + expect(h.submitted).toHaveBeenCalledOnce() + } + } finally { + settings.resolve(undefined) + await vi.waitFor(() => { expect(h.first.state.getSnapshot().busy).toBe(false) }) + } + }) + + it('restores input on preparation failure and dismisses the modal without losing it', async () => { + const h = harness() + h.first.shell.setDraft('preserve me') + h.first.shell.submit() + h.opened.reject(new Error('permission denied')) + await vi.waitFor(() => { expect(h.first.shell.snapshot.draft).toBe('preserve me') }) + expect(h.first.state.getSnapshot()).toEqual({ busy: false, failed: true }) + expect(h.submitted).not.toHaveBeenCalled() + h.first.dismiss() + expect(h.first.state.getSnapshot().failed).toBe(false) + expect(h.first.shell.snapshot.draft).toBe('preserve me') + }) + + it('hands a draft to a manual selection without sending it', async () => { + const h = harness() + h.first.shell.setDraft('manual folder') + h.first.transfer(SessionId('manual')) + expect(h.target.snapshot.draft).toBe('manual folder') + expect(h.first.shell.snapshot.draft).toBe('') + expect(h.submitted).not.toHaveBeenCalled() + }) + + it('lets manual navigation take the pending draft and cancels automatic submission', async () => { + const h = harness() + h.first.shell.setDraft('pending text') + h.first.shell.submit() + h.first.transfer(SessionId('manual')) + h.opened.resolve(undefined) + await vi.waitFor(() => { expect(h.first.state.getSnapshot().busy).toBe(false) }) + expect(h.target.snapshot.draft).toBe('pending text') + expect(h.first.shell.snapshot.draft).toBe('') + expect(h.submitted).not.toHaveBeenCalled() + }) + + it('restores a superseded draft without a directory error', async () => { + const h = harness() + h.first.shell.setDraft('later') + h.first.shell.submit() + h.opened.resolve(undefined) + await vi.waitFor(() => { expect(h.first.shell.snapshot.draft).toBe('later') }) + expect(h.first.state.getSnapshot().failed).toBe(false) + }) + + it('leaves ordinary send failures in the selected Session input', async () => { + const h = harness() + h.submitted.mockResolvedValue({ kind: 'error' }) + h.first.shell.setDraft('retry the prompt') + h.first.shell.submit() + h.complete() + await vi.waitFor(() => { expect(h.target.snapshot.draft).toBe('retry the prompt') }) + expect(h.first.shell.snapshot.draft).toBe('') + expect(h.first.state.getSnapshot().failed).toBe(false) + }) +}) diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 9e0366952c..a4805db1ec 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -185,6 +185,7 @@ function bench(over?: BenchOptions) { ? over?.plan : key === 'goal' ? over?.goal : key === 'imageLimits' ? over?.imageLimits : undefined)), + useComposerInput: bindSnapshotSelector(shell.state), useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 15d87a0ca7..c8641afcb4 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -67,6 +67,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled })), useProjection: (() => undefined), useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), + useComposerInput: bindSnapshotSelector(shell.state), useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 347b4d05c1..0c098d95fb 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -161,6 +161,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo })), useProjection: (() => undefined), useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), + useComposerInput: bindSnapshotSelector(shell.state), useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 17aadfb056..25f7002ea4 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -137,6 +137,8 @@ function mount( nestedSubagent?: boolean /** A composer block another plugin raised for this session. */ composerBlock?: { reason: string } + /** First-use preparation remains active after selecting its Session. */ + preparingFirstDraft?: boolean /** Mutable view ledger used by registration-order regressions. */ viewTabs?: ViewTab[] } = {}, @@ -280,6 +282,7 @@ function mount( useProjection={(() => undefined)} useInput={useInput} inputActions={inputActions} + useComposerInput={useInput} keyboard={wiring} addFiles={() => null} useFileUploads={bindSnapshotSelector(createSnapshotStore({}))} @@ -336,6 +339,8 @@ function mount( useWorkspaces: bindSnapshotSelector(workspaces), useProjection: (() => undefined), useComposerBlock: select => select(options.composerBlock), + useFirstDraft: select => select({ busy: options.preparingFirstDraft === true, failed: false }), + dismissDefaultFailure: () => {}, useInput, inputActions, renderSlot, @@ -405,6 +410,17 @@ describe('ConversationRoot resident composer', () => { expect(b.view.queryByTestId('view-conversation.session.header.corner')).toBeNull() }) + it('locks the selected Session draft until automatic first-use preparation settles', () => { + const b = mount(sessionSnapshotOf({ blank: true }), undefined, undefined, { + summaryBlank: true, preparingFirstDraft: true, + }) + const box = b.view.getByRole('textbox') + expect(box.getAttribute('aria-disabled')).toBe('true') + expect(box.getAttribute('aria-haspopup')).toBeNull() + fireEvent.keyDown(box, { key: 'Enter' }) + expect(b.sink).not.toHaveBeenCalled() + }) + it('does not redispatch composer child slots for an unrelated Session publication', () => { const b = mount(sessionSnapshotOf()) const childKeys = new Set([ diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 5f4245e471..951fd978ab 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/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-workspace/README.md -README.md: 0125f563e7d0f062dd37f58a218d76dd85b0fa51 -README.zh.md: 9db3ea3a39f7c314ebb7d874d858457f00501bd7 +README.md: 6b34c9bf764d04df591317a22c55a47c2307459d +README.zh.md: 550490a49651d01e20537cb924a43112a233966c diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 0125f563e7..6b34c9bf76 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -69,6 +69,8 @@ The value is intentionally best effort for cold Sessions. An identity-matching u New Session tries to acquire the first eligible blank in catalog order; startup restoration tries the saved blank. If that writer is held, navigation creates a new Session without trying other blanks. Other acquisition failures abort the request and are currently reported only to the console. Released blanks retain their slash-command state when reused. Later navigation cancels a pending startup selection. +`openDefaultWorkspace(request, beforeOpen, signal)` initializes the Host default Workspace from the requested directory name and title before connecting its Session. It captures navigation cancellation before directory preparation; superseded work cannot transfer the draft or select its Session. A successfully registered Workspace remains available when Session creation or later submission fails. [Conversation](../ui-conversation/README.md) owns the first-use draft and failure dialog. + ## Understand the implementation diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 9db3ea3a39..550490a496 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -69,6 +69,8 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示** 新建会话尝试获取列表中第一个符合条件的空白会话;启动恢复尝试已保存的空白会话。若该写锁被占用,导航直接新建 Session,不再尝试其他空白会话。其他获取错误会中止请求,目前只报告到控制台。已释放的空白会话被复用时保留 slash 命令状态。后续导航会取消尚未完成的启动选择。 +`openDefaultWorkspace(request, beforeOpen, signal)` 在连接 Session 前,按请求中的目录名和标题初始化 Host 默认工作区。它在目录准备之前捕获导航取消信号;被取代的操作不能转移草稿或选中 Session。登记成功的工作区在 Session 创建或后续提交失败时仍然保留。[Conversation](../ui-conversation/README.zh.md) 负责首次使用草稿和失败弹窗。 + ## 理解实现 diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index 22138ec707..e6ccd65f62 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -12,7 +12,7 @@ import type { import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { - IWorkspaces, WorkspaceId, WorkspaceSnapshot, WorkspaceView, + IWorkspaces, WorkspaceId, WorkspaceInitializeDefaultRequest, WorkspaceSnapshot, WorkspaceView, } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' @@ -36,6 +36,18 @@ export interface UiWorkspace { * @returns completion; a superseded request may create a Session but does not open it. */ openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise + /** + * Prepare the first-use Workspace and select its blank Session. + * @param request - initial directory name and title. + * @param beforeOpen - synchronous draft transfer after retaining the Session. + * @param signal - caller cancellation. + * @returns selected Session, or undefined after navigation supersession. + */ + openDefaultWorkspace( + request: WorkspaceInitializeDefaultRequest, + beforeOpen: (sessionId: SessionId) => void, + signal: AbortSignal, + ): Promise /** * Fork a Session without changing the current selection. * @param sessionId - source Session. @@ -184,6 +196,25 @@ class UiWorkspaceService extends Service implements UiWorkspace { this.replaceMain(sessionId, navigation, 'reveal', beforeOpen) } + async openDefaultWorkspace( + request: WorkspaceInitializeDefaultRequest, + beforeOpen: (sessionId: SessionId) => void, + signal: AbortSignal, + ): Promise { + const navigation = AbortSignal.any([this.ctx.layout.beginNavigation(), this.lifetime.signal, signal]) + try { + const workspace = await this.workspaces.initializeDefault(request, navigation) + navigation.throwIfAborted() + const sessionId = await this.connectWorkspace(workspace.workspaceId) + navigation.throwIfAborted() + this.replaceMain(sessionId, navigation, 'reveal', beforeOpen) + return sessionId + } catch (error) { + if (navigation.aborted) return undefined + throw error + } + } + async forkSession(sessionId: SessionId): Promise { await this.sessions.fork({ sessionId, increaseTitle: true }) } diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index 310bb3627d..a952cb9d32 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -167,6 +167,11 @@ class FakeSessions implements ISessions { } class FakeWorkspaces implements IWorkspaces { + readonly initializeDefault = vi.fn(async () => { + const item = workspace('default') + this.list.set(workspaceState([item])) + return item + }) readonly list: MutableSource readonly archiveCalls: SessionId[] = [] readonly unarchiveCalls: SessionId[] = [] @@ -272,6 +277,41 @@ function bench(options: BenchOptions = {}) { } describe('UiWorkspaceService', () => { + it('prepares the default Workspace before creating and retaining its Session', async () => { + const b = bench({ workspaces: workspaceState(), sessions: sessionState() }) + const beforeOpen = vi.fn(() => { + expect(b.sessions.retain).toHaveBeenCalledWith(sid('created-default'), { source: 'mainView' }) + }) + await expect(b.uiWorkspace.openDefaultWorkspace({ directoryName: '默认工作区', title: '默认工作区' }, beforeOpen, new AbortController().signal)) + .resolves.toBe(sid('created-default')) + expect(b.workspaces.initializeDefault).toHaveBeenCalledWith({ directoryName: '默认工作区', title: '默认工作区' }, expect.any(AbortSignal)) + expect(b.sessions.create).toHaveBeenCalledWith({ workspaceId: wid('default') }) + expect(beforeOpen).toHaveBeenCalledWith(sid('created-default')) + }) + + it('does not create a Session when default preparation fails or is superseded', async () => { + const b = bench({ workspaces: workspaceState(), sessions: sessionState() }) + b.workspaces.initializeDefault.mockRejectedValueOnce(new Error('denied')) + await expect(b.uiWorkspace.openDefaultWorkspace({ directoryName: 'Default workspace', title: 'Default workspace' }, vi.fn(), new AbortController().signal)).rejects.toThrow('denied') + expect(b.sessions.create).not.toHaveBeenCalled() + const pending = Promise.withResolvers() + b.workspaces.initializeDefault.mockReturnValueOnce(pending.promise) + const beforeOpen = vi.fn() + const opening = b.uiWorkspace.openDefaultWorkspace({ directoryName: 'Default workspace', title: 'Default workspace' }, beforeOpen, new AbortController().signal) + b.uiWorkspace.openSession(sid('manual')) + pending.resolve(workspace('default')) + await expect(opening).resolves.toBeUndefined() + expect(b.sessions.create).not.toHaveBeenCalled() + expect(beforeOpen).not.toHaveBeenCalled() + }) + + it('retains the prepared Workspace if creating its Session fails', async () => { + const b = bench({ workspaces: workspaceState(), sessions: sessionState() }) + b.sessions.create.mockRejectedValueOnce(new Error('session failed')) + await expect(b.uiWorkspace.openDefaultWorkspace({ directoryName: 'Default workspace', title: 'Default workspace' }, vi.fn(), new AbortController().signal)).rejects.toThrow('session failed') + expect(b.workspaces.list.getSnapshot().items).toEqual([workspace('default')]) + }) + it('retains an explicit main target before revealing its Conversation', () => { const b = bench() b.uiWorkspace.openSession(sid('target')) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 78975d4a43..407b489580 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -3250,6 +3250,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'request', description: 'directory path to register.' }], returns: 'the Workspace and whether this call created it.', }, + { + signature: '@Remote(\'initializeDefault\') async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise', + description: 'Initialize or reuse the default Workspace before the first user message.', + parameters: [{ name: 'request', description: 'initial directory name and title; never rename an existing default.' }, { name: 'signal', description: 'caller lifetime; cancels native directory lookup.' }], + returns: 'the durable Workspace without creating a Session or sending a message.', + }, { signature: '@Remote(\'rename\') rename(request: WorkspaceRenameRequest): Promise', description: 'Rename one Workspace to a unique non-blank title.', @@ -3367,6 +3373,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'path', description: 'Existing directory to own, in a fully qualified path spelling.' }, { name: 'title', description: 'Display title used only when a new record is created.' }], returns: 'the existing or newly durable workspace.', }, + { + signature: 'initializeDefault(resolveDirectory: () => Promise<{ path: string; title: string }>): Promise', + description: 'Initialize the default Workspace only while both the registry and Session history are empty. Repeated requests reuse its durable identity; deleting that registration permanently disables automatic creation.', + parameters: [{ name: 'resolveDirectory', description: 'resolve the absolute directory and initial title; called only for eligible creation, inside the registry mutation queue. Missing directories are created recursively before registration.' }], + returns: 'the initialized Workspace, or undefined when automatic creation is ineligible.', + }, { signature: 'get(id: WorkspaceId): Workspace | undefined', description: 'Look up a workspace by id.', @@ -7279,6 +7291,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'WorkspaceFollowIncrement', declaration: 'export type WorkspaceFollowIncrement = {\n readonly type: \'upsert\';\n readonly workspace: WorkspaceView;\n} | {\n readonly type: \'remove\';\n readonly workspaceId: WorkspaceId;\n} | {\n readonly type: \'order\';\n readonly workspaceIds: readonly WorkspaceId[];\n} | {\n readonly type: \'archived\';\n readonly archivedSessionIds: readonly SessionId[];\n} | {\n readonly type: \'pinned\';\n readonly pinnedSessionIds: readonly SessionId[];\n};', }, + { + name: 'WorkspaceInitializeDefaultRequest', + declaration: 'export interface WorkspaceInitializeDefaultRequest {\n readonly directoryName: string;\n readonly title: string;\n}', + }, { name: 'WorkspaceInsertBeforeRequest', declaration: 'export interface WorkspaceInsertBeforeRequest {\n readonly workspaceId: WorkspaceId;\n readonly beforeWorkspaceId?: WorkspaceId;\n}', diff --git a/packages/test-support/client-runtime/src/workspaces.ts b/packages/test-support/client-runtime/src/workspaces.ts index 68a5c61d09..2c6b788fa3 100644 --- a/packages/test-support/client-runtime/src/workspaces.ts +++ b/packages/test-support/client-runtime/src/workspaces.ts @@ -1,7 +1,7 @@ /** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { - IWorkspaces, WorkspaceId, WorkspaceSnapshot, WorkspaceView, + IWorkspaces, WorkspaceId, WorkspaceInitializeDefaultRequest, WorkspaceSnapshot, WorkspaceView, } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -80,6 +80,19 @@ export class TestWorkspaces implements IWorkspaces { } as unknown as WorkspaceView } + /** + * Initialize the default Workspace through an explicitly installed test stub. + * @param request - initial directory name and title. + * @param signal - caller lifetime. + * @returns the stubbed Workspace. + */ + async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise { + this.calls.push({ method: 'initializeDefault', args: [request, signal] }) + const stub = this.stubs.get('initializeDefault') + if (stub === undefined) throw new Error('initializeDefault requires a Workspace fixture') + return await (stub(request, signal) as Promise) + } + /** * Rename a Workspace (recorded). The default echoes a minimal view. * @param workspaceId - target workspace. diff --git a/packages/workspace/workspace/README.i18n.yaml b/packages/workspace/workspace/README.i18n.yaml index da38092a17..43e9c7d159 100644 --- a/packages/workspace/workspace/README.i18n.yaml +++ b/packages/workspace/workspace/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/workspace/workspace/README.md -README.md: 51fa66d346ffc4027397d8851b0c81ea87269ded -README.zh.md: 102308d3257ba13d3ec851adcbdb1d4c159abc14 +README.md: f11d6496c04cf346c21f869e00ec5fb5526008db +README.zh.md: 3d458e13157bda612c3d33d0e7bfb57fa914e9b3 diff --git a/packages/workspace/workspace/README.md b/packages/workspace/workspace/README.md index 51fa66d346..f11d6496c0 100644 --- a/packages/workspace/workspace/README.md +++ b/packages/workspace/workspace/README.md @@ -33,7 +33,7 @@ Use it when the product shows a persistent workspace surface — a sidebar, sess ### Setting up -The package takes no configuration of its own; it needs a session store, a session persistence backend, and the storage rows that keep its records. A minimal composition: +The package needs a session store, a session persistence backend, and the storage rows that keep its records. A minimal composition: ```yaml - name: '@deepseek-ai/dsh-session' @@ -59,6 +59,15 @@ await project.setTitle('Renamed') ctx.workspaceRegistry.list() // shows the project, newest first ``` + +### First-use Workspace + +`initializeDefault(resolveDirectory)` initializes the default Workspace without creating a Session. Initial creation requires an empty Workspace registry and no live, persisted, or archived Session, including Sessions without a working directory. The registry checks persistent history directly; an empty visible sidebar is insufficient. + +The directory resolver runs inside the mutation queue only when creation is eligible. It returns an absolute path and initial title; the registry creates missing parent directories, canonicalizes the path, rechecks Session history, and commits the Workspace with its initialization marker. An existing directory is reused; a file conflict or directory failure rejects initialization. The [Host controller](../../api/workspace-controller/README.md#first-use-workspace) supplies the Documents path policy. + +The first successful registration records its identity durably. Repeated calls return it without resolving a directory again; renaming keeps that identity, and deleting its registration does not permit another automatic creation. Directory or registration failure leaves initialization unset for retry. Directories created before a later failure remain on disk. The [first-use decision](../../../.agents/notes/implemented/feature/2026-09-20-default-workspace.md) explains this lifetime. + ### Grouping sessions under a project A session joins the project of the directory it runs in: create a session in a project's directory and it appears under that project, newest first. A session can only belong to one project. A session whose directory cannot be validated — no recorded directory, or a moved or deleted folder — cannot join and stays ungrouped. @@ -102,7 +111,7 @@ The API has two owners: `WorkspaceRegistry` creates, orders, and deletes project ### Durable shape -The registry opens the `workspace` domain (version 2): a `workspaces` table keyed by `WorkspaceId` plus one global state holding `workspaceIds` (the authoritative display order), `archivedSessionIds`, `pinnedSessionIds`, and the optional `pendingMutation` marker. Archive and pin sets contain Session id strings, default to empty, and carry no per-entry objects or timestamps; the pin array keeps the most recently pinned id first. Archiving clears the pin in the same global-state write without changing Workspace membership. Unarchive runs no session-existence probe, because dropping an id from the set cannot introduce an unknown one, while archive verifies the session before adding it. +The registry opens the `workspace` domain (version 2): a `workspaces` table keyed by `WorkspaceId` plus one global state holding `workspaceIds` (the authoritative display order), `archivedSessionIds`, `pinnedSessionIds`, the optional `defaultWorkspaceId` first-use identity, and the optional `pendingMutation` marker. Archive and pin sets contain Session id strings, default to empty, and carry no per-entry objects or timestamps; the pin array keeps the most recently pinned id first. Archiving clears the pin in the same global-state write without changing Workspace membership. Unarchive runs no session-existence probe, because dropping an id from the set cannot introduce an unknown one, while archive verifies the session before adding it. ### Lifecycle diff --git a/packages/workspace/workspace/README.zh.md b/packages/workspace/workspace/README.zh.md index 102308d325..3d458e1315 100644 --- a/packages/workspace/workspace/README.zh.md +++ b/packages/workspace/workspace/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" ### 设置 -此包本身不声明任何配置;它需要会话存储、会话持久化后端,以及保存其记录的存储行。最小组合如下: +此包需要会话存储、会话持久化后端,以及保存其记录的存储行。最小组合如下: ```yaml - name: '@deepseek-ai/dsh-session' @@ -59,6 +59,15 @@ await project.setTitle('Renamed') ctx.workspaceRegistry.list() // shows the project, newest first ``` + +### 首次使用工作区 + +`initializeDefault(resolveDirectory)` 初始化默认 Workspace,不创建 Session。首次创建要求 Workspace 注册表为空,且不存在运行时、持久化或已归档 Session,包括没有工作目录的 Session。注册表直接检查持久化历史;仅凭可见侧边栏为空不足以判断。 + +目录解析器仅在允许创建时于变更队列内运行。它返回绝对路径和初始标题;注册表创建缺失的父目录、规范化路径、重新检查 Session 历史,再一起提交 Workspace 和初始化标记。已存在的目录直接复用;文件冲突或目录操作失败时拒绝初始化。[Host 控制器](../../api/workspace-controller/README.zh.md#first-use-workspace)提供 Documents 路径策略。 + +首次成功登记会持久保存工作区身份。重复调用直接返回它,不再解析目录;改名保留该身份,删除登记也不会允许再次自动创建。目录或登记失败时,初始化状态保持未设置,可以重试。后续步骤失败前已创建的目录会保留在磁盘上。[首次使用决策](../../../.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md)说明这一生命周期。 + ### 将会话归入项目 会话加入它运行目录所在的项目:在项目目录中创建会话,它就会出现在该项目下,新到旧排列。一个会话只能属于一个项目。目录无法校验的会话——没有记录目录,或目录被移动、删除——无法加入,保持 Ungrouped。 @@ -102,7 +111,7 @@ ctx.workspaceRegistry.list() // shows the project, newest first ### 持久形态 -注册表打开 `workspace` 领域(版本 2):一张以 `WorkspaceId` 为键的 `workspaces` 表,加上一个持有 `workspaceIds`(权威显示顺序)、`archivedSessionIds`、`pinnedSessionIds` 与可选 `pendingMutation` 标记的全局状态。归档与置顶集合存储会话 id 字符串,默认值为空,不包含逐项对象或时间戳;置顶数组把最近置顶的 id 放在前面。归档在同一次全局状态写入中清除置顶,但不改变 Workspace 成员关系。取消归档不做会话存在性探测,因为从集合中移除 id 不可能引入未知 id,而归档会在加入前校验会话。 +注册表打开 `workspace` 领域(版本 2):一张以 `WorkspaceId` 为键的 `workspaces` 表,加上一个持有 `workspaceIds`(权威显示顺序)、`archivedSessionIds`、`pinnedSessionIds`、可选的首次使用身份 `defaultWorkspaceId` 与可选 `pendingMutation` 标记的全局状态。归档与置顶集合存储会话 id 字符串,默认值为空,不包含逐项对象或时间戳;置顶数组把最近置顶的 id 放在前面。归档在同一次全局状态写入中清除置顶,但不改变 Workspace 成员关系。取消归档不做会话存在性探测,因为从集合中移除 id 不可能引入未知 id,而归档会在加入前校验会话。 ### 生命周期 diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index ab9ff50409..6cb179022a 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -6,7 +6,7 @@ */ import { randomUUID } from 'node:crypto' -import { stat } from 'node:fs/promises' +import { mkdir, stat } from 'node:fs/promises' import { Context, Service } from '@deepseek-ai/cordis' import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-persistence' @@ -15,7 +15,7 @@ import { WorkspaceEntity } from './entity.ts' import type { WorkspaceEntityHost } from './entity.ts' export { WorkspaceMoveInvalidError } from './entity.ts' -import { defaultWorkspaceTitle, realpathNormalize } from './paths.ts' +import { defaultWorkspaceTitle, fullyQualifiedWorkspacePath, realpathNormalize } from './paths.ts' import { workspaceDomainSpec } from './spec.ts' import type { WorkspaceDomainState, WorkspaceRecord } from './spec.ts' import type { Workspace, WorkspaceId as WorkspaceIdBrand } from './types.ts' @@ -175,6 +175,34 @@ export class WorkspaceRegistry extends Service { return await this.enqueueOperation(() => this.createCanonical(canonical, title)) } + /** + * Initialize the default Workspace only while both the registry and Session + * history are empty. Repeated requests reuse its durable identity; deleting + * that registration permanently disables automatic creation. + * @param resolveDirectory - resolve the absolute directory and initial title; + * called only for eligible creation, inside the registry mutation queue. + * Missing directories are created recursively before registration. + * @returns the initialized Workspace, or undefined when automatic creation is ineligible. + */ + initializeDefault(resolveDirectory: () => Promise<{ path: string; title: string }>): Promise { + return this.enqueueOperation(async () => { + const state = this.requireState() + if (state.defaultWorkspaceId !== undefined) return this.entities.get(state.defaultWorkspaceId) + const sessions = this.ctx.get('sessions') + if (sessions === undefined) throw new Error('default Workspace initialization requires the Session store') + if (state.workspaceIds.length > 0 || state.archivedSessionIds.length > 0 + || sessions.list().length > 0 || (await this.listStoredHeaders()).length > 0) return undefined + + const { path, title } = await resolveDirectory() + if (!fullyQualifiedWorkspacePath(path)) throw new TypeError(`Workspace path is not fully qualified: '${path}'`) + await mkdir(path, { recursive: true }) + const canonical = await realpathNormalize(path) + // A Session can start outside the registry queue while directory preparation awaits I/O. + if ((await this.listStoredHeaders()).length > 0 || sessions.list().length > 0) return undefined + return this.createCanonical(canonical, title, true) + }) + } + /** * Look up a workspace by id. * @param id - Workspace id. @@ -379,7 +407,7 @@ export class WorkspaceRegistry extends Service { return undefined } - private async createCanonical(canonical: string, title?: string): Promise { + private async createCanonical(canonical: string, title?: string, firstUse = false): Promise { for (const entity of this.entities.values()) { if (entity.path === canonical) return entity } @@ -425,7 +453,10 @@ export class WorkspaceRegistry extends Service { try { await this.setState({ + ...state, + pendingMutation: undefined, initialized: true, + ...(firstUse ? { defaultWorkspaceId: id } : {}), workspaceIds: [id, ...state.workspaceIds], archivedSessionIds: state.archivedSessionIds, pinnedSessionIds: state.pinnedSessionIds, @@ -458,6 +489,8 @@ export class WorkspaceRegistry extends Service { if (entity === undefined) return false const state = this.requireState() const nextState = { + ...state, + pendingMutation: undefined, initialized: true, workspaceIds: state.workspaceIds.filter(workspaceId => workspaceId !== id), archivedSessionIds: state.archivedSessionIds, @@ -516,6 +549,8 @@ export class WorkspaceRegistry extends Service { } await this.requireTable().delete(pending.workspaceId) await this.setState({ + ...state, + pendingMutation: undefined, initialized: state.initialized, workspaceIds: state.workspaceIds, archivedSessionIds: state.archivedSessionIds, diff --git a/packages/workspace/workspace/src/spec.ts b/packages/workspace/workspace/src/spec.ts index 165bff34e0..9bd792258a 100644 --- a/packages/workspace/workspace/src/spec.ts +++ b/packages/workspace/workspace/src/spec.ts @@ -56,6 +56,8 @@ const workspacePendingMutation = z.discriminatedUnion('operation', [ */ export const workspaceDomainState = z.object({ initialized: z.boolean(), + /** First-use Workspace identity, retained after its registration is deleted. */ + defaultWorkspaceId: workspaceId.optional(), workspaceIds: z.array(workspaceId), archivedSessionIds: z.array(sessionId).default([]), pinnedSessionIds: z.array(sessionId).default([]), diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index f860028d4e..6eafae9f2e 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -2,7 +2,7 @@ import type { z } from 'zod' import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { basename, join } from 'node:path' +import { basename, join, relative } from 'node:path' import { Context } from '@deepseek-ai/cordis' import Storage from '@deepseek-ai/dsh-storage' import type { StorageBackend } from '@deepseek-ai/dsh-storage' @@ -1156,3 +1156,145 @@ describe('registry-global session unpin', () => { expect(result.list.mock.calls.length).toBe(listingsBefore) }) }) + +describe('first-use Workspace preparation', () => { + const contexts: Context[] = [] + + afterEach(async () => { + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() + }) + + async function firstUse(options: HarnessOptions = {}) { + const directoryRoot = await makeDir('first-use') + const result = await harness({ liveSessions: [], ...options }) + contexts.push(result.ctx) + const resolveDirectory = vi.fn(async () => ({ path: join(directoryRoot, 'nested', 'Workspace'), title: 'Workspace' })) + return { ...result, directoryRoot, resolveDirectory } + } + + it('creates parent directories and resolves only one directory for concurrent initialization', async () => { + const h = await firstUse() + const laterDirectory = vi.fn(async () => { throw new Error('directory lookup unavailable') }) + const [first, repeated] = await Promise.all([ + h.registry.initializeDefault(h.resolveDirectory), h.registry.initializeDefault(laterDirectory), + ]) + expect(first).toBeDefined() + expect(repeated).toBe(first) + expect(h.resolveDirectory).toHaveBeenCalledOnce() + expect(laterDirectory).not.toHaveBeenCalled() + expect(first?.path).toBe(await realpath(join(h.directoryRoot, 'nested', 'Workspace'))) + expect(first?.title).toBe('Workspace') + expect(h.registry.list()).toEqual([first]) + expect(storedState(h.pool).defaultWorkspaceId).toBe(first?.id) + expect(h.ctx.sessions.list()).toEqual([]) + expect(h.open).not.toHaveBeenCalled() + }) + + it('reuses an existing directory without changing its contents', async () => { + const h = await firstUse() + const directory = join(h.directoryRoot, 'nested', 'Workspace') + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'keep.txt'), 'keep') + expect((await h.registry.initializeDefault(h.resolveDirectory))?.path).toBe(directory) + const { readFile } = await import('node:fs/promises') + expect(await readFile(join(directory, 'keep.txt'), 'utf8')).toBe('keep') + }) + + it('leaves initialization retryable when directory resolution fails', async () => { + const h = await firstUse() + h.resolveDirectory.mockRejectedValueOnce(new Error('lookup unavailable')) + await expect(h.registry.initializeDefault(h.resolveDirectory)).rejects.toThrow('lookup unavailable') + expect(storedState(h.pool).defaultWorkspaceId).toBeUndefined() + expect(await h.registry.initializeDefault(h.resolveDirectory)).toBeDefined() + }) + + it('rejects a relative candidate before creating its directory', async () => { + const h = await firstUse() + const candidate = join(h.directoryRoot, 'relative') + h.resolveDirectory.mockResolvedValueOnce({ path: relative(process.cwd(), candidate), title: 'Workspace' }) + await expect(h.registry.initializeDefault(h.resolveDirectory)).rejects.toThrow('fully qualified') + await expect(realpath(candidate)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(h.registry.list()).toEqual([]) + expect(storedState(h.pool).defaultWorkspaceId).toBeUndefined() + }) + + it('keeps the initialization marker across deletion and restart', async () => { + const h = await firstUse() + const workspace = (await h.registry.initializeDefault(h.resolveDirectory))! + await workspace.setTitle('Renamed') + expect((await h.registry.initializeDefault(h.resolveDirectory))?.title).toBe('Renamed') + await h.registry.delete(workspace.id) + await h.ctx.fiber.dispose() + const restarted = await firstUse({ pool: h.pool }) + await expect(restarted.registry.initializeDefault(restarted.resolveDirectory)).resolves.toBeUndefined() + expect(storedState(h.pool).defaultWorkspaceId).toBe(workspace.id) + expect(restarted.registry.list()).toEqual([]) + expect(restarted.resolveDirectory).not.toHaveBeenCalled() + }) + + it.each(['persisted', 'live', 'archived'] as const)('refuses automatic creation for a %s cwd-less Session', async (kind) => { + const history = header('old') + const h = await firstUse(kind === 'persisted' ? { sessions: [history] } : { liveSessions: [history] }) + if (kind === 'archived') await h.registry.archiveSession(history.id) + await expect(h.registry.initializeDefault(h.resolveDirectory)).resolves.toBeUndefined() + expect(h.registry.list()).toEqual([]) + expect(storedState(h.pool).defaultWorkspaceId).toBeUndefined() + expect(h.resolveDirectory).not.toHaveBeenCalled() + }) + + it('refuses a non-empty Workspace list and newly persisted history', async () => { + const h = await firstUse() + const explicit = await h.registry.create(h.directoryRoot) + await expect(h.registry.initializeDefault(h.resolveDirectory)).resolves.toBeUndefined() + await h.registry.delete(explicit.id) + h.setSessions([header('arrived')]) + await expect(h.registry.initializeDefault(h.resolveDirectory)).resolves.toBeUndefined() + }) + + it('fails on a same-path file and remains eligible after it is removed', async () => { + const h = await firstUse() + const parent = join(h.directoryRoot, 'nested') + await mkdir(parent) + const path = join(parent, 'Workspace') + await writeFile(path, 'occupied') + await expect(h.registry.initializeDefault(h.resolveDirectory)).rejects.toThrow() + expect(storedState(h.pool).defaultWorkspaceId).toBeUndefined() + expect(h.registry.list()).toEqual([]) + await rm(path) + expect((await h.registry.initializeDefault(h.resolveDirectory))?.path).toBe(path) + }) + + it.each(['persisted', 'live'] as const)('refuses registration when a %s Session appears during directory preparation', async (kind) => { + const h = await firstUse() + const arrived = header('arrived-during-preparation') + if (kind === 'persisted') { + h.list.mockResolvedValueOnce([]).mockResolvedValueOnce([ + { header: arrived, revision: SessionPersistenceRevision('arrived-revision') }, + ]) + } else { + vi.spyOn(h.ctx.sessions, 'list').mockReturnValueOnce([]).mockReturnValueOnce([{ header: arrived }] as never) + } + await expect(h.registry.initializeDefault(h.resolveDirectory)).resolves.toBeUndefined() + expect(h.registry.list()).toEqual([]) + expect(storedState(h.pool).defaultWorkspaceId).toBeUndefined() + }) + + it('does not infer an empty live Session store when the peer is unavailable', async () => { + const h = await harness() + contexts.push(h.ctx) + const resolveDirectory = vi.fn(async () => { throw new Error('unexpected directory lookup') }) + await expect(h.registry.initializeDefault(resolveDirectory)).rejects.toThrow('Session store') + expect(resolveDirectory).not.toHaveBeenCalled() + expect(storedState(h.pool).defaultWorkspaceId).toBeUndefined() + }) + + it('rolls back a failed final marker write and allows a retry', async () => { + const pool = new MemoryMediaPool() + const h = await firstUse({ pool, backend: selectiveFailureBackend(pool, { globalAt: 3 }) }) + await expect(h.registry.initializeDefault(h.resolveDirectory)).rejects.toThrow('marker failure') + expect(h.registry.list()).toEqual([]) + expect(storedState(pool).defaultWorkspaceId).toBeUndefined() + await expect(realpath(join(h.directoryRoot, 'nested', 'Workspace'))).resolves.toBe(join(h.directoryRoot, 'nested', 'Workspace')) + expect(await h.registry.initializeDefault(h.resolveDirectory)).toBeDefined() + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc16be7fbf..6543dd2ade 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1297,6 +1297,12 @@ importers: '@deepseek-ai/dsh-deque': specifier: workspace:^ version: link:../../util/deque + '@deepseek-ai/dsh-native-command': + specifier: workspace:^ + version: link:../../util/native-command + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index c3a8c6a239..1f19959e7e 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -246,6 +246,7 @@ export const EVENT_SCOPE_PAGE: Record = { export const EVENT_WALK_EXEMPTIONS: Record = { 'command/executed': 'client-face local command acknowledgment — packages/client/ui-commands/README.md owns the API', 'connection/reset': 'client-face transport signal — packages/api/session-controller/README.md owns the API', + 'conversation/prepare-first-send': 'client-face first-use draft preparation — packages/client/ui-conversation/README.md owns the API', 'locale/change': 'client-face locale switch signal — packages/client/locale/README.md owns the API', 'slash/input-begin-command': 'client-face slash-input protocol — packages/client/ui-input-trigger/README.md owns the API', 'slash/input-consume-token': 'client-face slash-input protocol — packages/client/ui-input-trigger/README.md owns the API', @@ -681,6 +682,7 @@ export const LINK_MAP: Readonly> = { WorkspaceArchiveSessionRequest: 'workspace.md', WorkspaceArchiveValue: 'workspace.md', WorkspaceCreateRequest: 'workspace.md', + WorkspaceInitializeDefaultRequest: 'workspace.md', WorkspaceCreateValue: 'workspace.md', WorkspaceDeleteRequest: 'workspace.md', WorkspaceDeleteValue: 'workspace.md', diff --git a/snapshots/web/default-workspace/snapshot.yml b/snapshots/web/default-workspace/snapshot.yml new file mode 100644 index 0000000000..d1eed14d70 --- /dev/null +++ b/snapshots/web/default-workspace/snapshot.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: default-workspace +profile: web +composition: web-default +recording: authored +header: + class: web-default +session: + source: ../fresh-round-trip/session.v3.jsonl diff --git a/snapshots/web/default-workspace/ui.expected.md b/snapshots/web/default-workspace/ui.expected.md new file mode 100644 index 0000000000..425343834b --- /dev/null +++ b/snapshots/web/default-workspace/ui.expected.md @@ -0,0 +1,26 @@ +- banner: + - navigation "Session hierarchy": Use the bash tool to + - text: Standard mode + - button "More actions" + - button "Open right sidebar" + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- button "System prompt" +- text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" +- button "Copy" +- button "1 tool call" +- paragraph: DONE +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- text: {{clock}} +- textbox "Message or run a task, / commands, @ files or sessions" +- button "Add files or run commands" +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash +- button "Send message" [disabled] +- button "1 turns 2 steps · {{throughput}} tok/s": 1 turns 2 steps{{throughput}} tok/s +- button "15.8K tok · Cache hit 99%": 15.8K tokCache hit 99% +- button "6% of context used": 6% diff --git a/tsconfig.host.json b/tsconfig.host.json index c1088e0ae8..0e9797b99b 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -63,6 +63,7 @@ "apps/web/tests/public-mount.e2e.ts", "apps/web/tests/workspace-new-session-folding.e2e.ts", "apps/web/tests/workspace-management.e2e.ts", + "apps/web/tests/default-workspace.e2e.ts", "apps/web/tests/workspace-recency.e2e.ts", "apps/web/tests/session-unarchive.e2e.ts", "apps/web/tests/replay-round-trip.e2e.ts", From 6fa56a0cf7c0d7d588a4f94549e82a4936e2cfa6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sun, 20 Sep 2026 15:57:45 +0800 Subject: [PATCH 04/60] fix(conversation): preserve first drafts across Session retirement --- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 2 + .../src/client/input/first-draft.ts | 5 +- .../tests/apply-inject.client.spec.tsx | 61 ++++++++++++++++++- .../tests/first-draft.client.spec.ts | 2 +- 7 files changed, 71 insertions(+), 7 deletions(-) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 2002de04c1..d9ee350eac 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: 56b9d06ce5e48ab40691bede2ccb9669e180aa9f -README.zh.md: fd02565698171e0b6db35080181e57b8b5abd42d +README.md: 6ea403d46b9a019cbd24f8963a89835be4745980 +README.zh.md: f6e28d563847316b538ab8c4317507ea7761c913 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 56b9d06ce5..6ea403d46b 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -52,7 +52,7 @@ Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and c The Client chooses the default directory name and initial title when sending: Chinese uses `默认工作区`, English uses `Default workspace`, and other languages use directory `default-workspace` with title `Default workspace`. The request retains those names during initialization. A retry after failed initialization uses the current language; a successfully initialized Workspace keeps its directory and title across language changes. -On first use, the same input accepts text before a Workspace is selected. The first send prepares the [default Workspace](../../workspace/workspace/README.md#first-use-workspace), selects its blank Session, transfers the draft, and uses ordinary message submission. Preparation locks further input. Failure preserves the draft and offers Cancel or Choose folder through the existing picker. A manually chosen folder receives the unsent draft; the user sends again. Once created, the Workspace survives a prompt failure. `conversation/prepare-first-send` awaits staged Session settings before automatically submitting the transferred first-use draft; a rejected listener keeps that draft and reports its error. +On first use, the same input accepts text before a Workspace is selected. The first send prepares the [default Workspace](../../workspace/workspace/README.md#first-use-workspace), selects its blank Session, transfers the draft, and uses ordinary message submission. Preparation locks further input. Failure preserves the draft and offers Cancel or Choose folder through the existing picker. A manually chosen folder receives the unsent draft; the user sends again. Once created, the Workspace survives a prompt failure. `conversation/prepare-first-send` awaits staged Session settings before automatically submitting the transferred first-use draft; a rejected listener keeps that draft and reports its error. If navigation releases the destination Session while preparation is pending, automatic submission stops and its persisted draft remains available on reopening. The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the resident `conversation.header` outside the optional-Session `conversation.content` Component Factory. The header hosts root-scoped navigation without a selected Session; titles, actions, and View tabs remain in its strict Session child. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes strict-Session `views` plus root-scoped `widthControls` local positions. Its default adapter renders the existing `conversation.session` entry, while the main occurrence selects the width handles; an embedded occurrence can replace `views` and omit those handles without rendering the main Header. The shared body and Composer register the queue and Todo docks. The Todo dock uses the shared panel elevation above the composer; its rows use the shared idle, ongoing, and done markers for pending, in-progress, and completed items. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index fd02565698..f6e28d5638 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -52,7 +52,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, Client 在发送时决定默认目录名和初始标题:中文使用 `默认工作区`,英文使用 `Default workspace`,其他语言使用目录 `default-workspace` 和标题 `Default workspace`。初始化期间,请求保留这些名称。初始化失败后的重试使用当前语言;初始化成功的 Workspace 在切换语言后仍保留原目录和标题。 -首次使用时,同一个输入框允许在选择 Workspace 前输入文字。首条消息会准备[默认工作区](../../workspace/workspace/README.zh.md#first-use-workspace)、选中其 blank Session、转移草稿,再使用普通消息提交流程。准备期间锁定后续输入。失败时保留草稿,并提供“取消”或通过已有选择器“选择文件夹”。手动选择的文件夹接收未发送草稿,用户再次发送。Workspace 创建成功后,不会因提示词发送失败而消失。`conversation/prepare-first-send` 在自动提交已转移的首次使用草稿前等待暂存的 Session 设置完成;监听器拒绝时保留该草稿并显示错误。 +首次使用时,同一个输入框允许在选择 Workspace 前输入文字。首条消息会准备[默认工作区](../../workspace/workspace/README.zh.md#first-use-workspace)、选中其 blank Session、转移草稿,再使用普通消息提交流程。准备期间锁定后续输入。失败时保留草稿,并提供“取消”或通过已有选择器“选择文件夹”。手动选择的文件夹接收未发送草稿,用户再次发送。Workspace 创建成功后,不会因提示词发送失败而消失。`conversation/prepare-first-send` 在自动提交已转移的首次使用草稿前等待暂存的 Session 设置完成;监听器拒绝时保留该草稿并显示错误。如果导航在准备期间释放了目标 Session,自动提交会停止,已持久化的草稿会在重新打开时保留。 本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` 外壳将常驻的 `conversation.header` 放在可选 Session 的 `conversation.content` Component Factory 外。未选中 Session 时,头部仍承载根作用域导航;标题、操作和 View 标签保留在严格 Session 子组件中。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开 strict-Session `views` 与 root-scoped `widthControls` 两个局部位置。默认 adapter 渲染现有 `conversation.session` entry,主 occurrence 选择宽度拖拽条;嵌入式 occurrence 可以替换 `views`、省略拖拽条,且不渲染主 Header。共享正文与 Composer 注册 queue dock 和 Todo dock。Todo dock 在 composer 上方使用共享面板 elevation;其中的行分别以共享 idle、ongoing 与 done 标记表示待处理、进行中与已完成。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 02c1f3a593..018cf29579 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -208,6 +208,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { const firstDraft = new FirstDraft(ctx, { open: (beforeOpen, signal) => { const language = ctx.locale.getSnapshot().active.toLowerCase().split('-')[0] + // Persisted Workspace names follow the send-time language, including the English fallback. const title = (language === 'zh' ? zh : en)['defaultWorkspace.title'] return workspaceNavigation.openDefaultWorkspace({ directoryName: language === 'zh' || language === 'en' ? title : 'default-workspace', @@ -215,6 +216,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { }, beforeOpen, signal) }, shell: id => inputHub.shell(id), + isCurrent: (id, shell) => sessions.binding(id) !== undefined && inputHub.shell(id) === shell, }) const composerBlocks = new ComposerBlockRegistry() diff --git a/packages/client/ui-conversation/src/client/input/first-draft.ts b/packages/client/ui-conversation/src/client/input/first-draft.ts index 9e1c486eae..8dc7fb32a6 100644 --- a/packages/client/ui-conversation/src/client/input/first-draft.ts +++ b/packages/client/ui-conversation/src/client/input/first-draft.ts @@ -10,13 +10,15 @@ import { SessionInputShell } from './facade.ts' interface FirstDraftDeps { open(beforeOpen: (id: SessionId) => void, signal: AbortSignal): Promise shell(id: SessionId): SessionInputShell + /** Whether the borrowed input still belongs to the retained Session generation. */ + isCurrent(id: SessionId, shell: SessionInputShell): boolean } /** Owns a root-lifetime text editor without allocating a Host Session. */ export class FirstDraft { /** Preparation progress and the recoverable directory failure. */ readonly state = createSnapshotStore({ busy: false, failed: false }) - /** Local editor that stays independent of Host Session allocation. */ + /** Local editor owns failed-submit restoration before transfer to a Host Session. */ readonly shell: SessionInputShell private pending: { text: string; transferred: boolean } | undefined @@ -63,6 +65,7 @@ export class FirstDraft { target = this.deps.shell(id) await this.ctx.serial('conversation/prepare-first-send', id) signal.throwIfAborted() + if (!this.deps.isCurrent(id, target)) return { kind: 'success' } target.submit(mode) return { kind: 'success' } } catch (error) { diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index abc74918d5..c35aff8227 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -139,11 +139,70 @@ async function bench() { return { runtime, feature, slots: runtime.slots, entryOf, conversationApi, headerApi, residentApi, composerApi, inputApi, viewSource, sessionFake, connectWorkspace, rootUpload, uploads, rootReference, references, opened, - locale, openDefaultWorkspace, + locale, openDefaultWorkspace, replaceMain, } } describe('Conversation inject API', () => { + it.each([false, true])('keeps the saved first draft after its Session retires (reopened during preparation: %s)', async (reopenEarly) => { + const b = await bench() + onTestFinished(() => b.runtime.dispose()) + const settings = Promise.withResolvers() + const started = Promise.withResolvers() + b.runtime.ctx.on('conversation/prepare-first-send', () => { + started.resolve(undefined) + return settings.promise + }) + b.openDefaultWorkspace.mockImplementation(async (_request, beforeOpen) => { + b.replaceMain(ROOT, beforeOpen) + return ROOT + }) + const body = b.conversationApi(ROOT) + const unmirror = body.injected.bindDraftMirror(body.instance.actions.setDraft) + const composer = b.composerApi(undefined) + let reopened: SessionReference | undefined + try { + composer.keyboard.paste('keep this first draft') + composer.keyboard.submit('queue') + await started.promise + expect(body.instance.store.getSnapshot().draft).toBe('keep this first draft') + const other = 'other-first-send' as SessionId + await b.runtime.sessions.add({ id: other, session: sessionFakeFor() }) + unmirror() + b.rootReference.release() + b.replaceMain(other) + await vi.waitFor(() => { expect(b.runtime.sessions.binding(ROOT)).toBeUndefined() }) + if (reopenEarly) reopened = b.runtime.sessions.retain(ROOT) + settings.resolve(undefined) + await vi.waitFor(() => { expect(b.residentApi(undefined).hooks.firstDraft.getSnapshot().busy).toBe(false) }) + await b.runtime.flush() + expect(b.sessionFake.prompt).not.toHaveBeenCalled() + reopened ??= b.runtime.sessions.retain(ROOT) + b.references.set(ROOT, reopened) + expect(b.conversationApi(ROOT).instance.store.getSnapshot().draft).toBe('keep this first draft') + expect(composer.hooks.composerInput.getSnapshot().draft).toBe('') + } finally { + settings.resolve(undefined) + reopened?.release() + unmirror() + } + }) + + it('submits the first draft through its retained destination input', async () => { + const b = await bench() + onTestFinished(() => b.runtime.dispose()) + b.openDefaultWorkspace.mockImplementation(async (_request, beforeOpen) => { + b.replaceMain(ROOT, beforeOpen) + return ROOT + }) + const composer = b.composerApi(undefined) + composer.keyboard.paste('first message') + composer.keyboard.submit('queue') + await vi.waitFor(() => { expect(b.sessionFake.prompt).toHaveBeenCalledOnce() }) + expect(b.sessionFake.prompt.mock.calls[0]?.[0]).toEqual([{ type: 'text', text: 'first message' }]) + expect(composer.hooks.composerInput.getSnapshot().draft).toBe('') + }) + it.each([ ['zh', '默认工作区', '默认工作区'], ['en', 'Default workspace', 'Default workspace'], diff --git a/packages/client/ui-conversation/tests/first-draft.client.spec.ts b/packages/client/ui-conversation/tests/first-draft.client.spec.ts index 3d223a38fe..b23055dcf1 100644 --- a/packages/client/ui-conversation/tests/first-draft.client.spec.ts +++ b/packages/client/ui-conversation/tests/first-draft.client.spec.ts @@ -27,7 +27,7 @@ function harness() { beforeOpen = prepare return opened.promise }) - const first = new FirstDraft(ctx, { open, shell: () => target }) + const first = new FirstDraft(ctx, { open, shell: () => target, isCurrent: () => true }) return { ctx, first, target, submitted, open, opened, complete: () => { From cfec29eb69bf8c4c78b388547502e324e0e1ecd4 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sun, 20 Sep 2026 15:58:10 +0800 Subject: [PATCH 05/60] fix(web): show first-use preparation and update browser readiness --- apps/web/tests/default-workspace.e2e.ts | 20 ++++++++++++++++--- .../default-workspace/preparing.expected.md | 7 +++++++ apps/web/tests/preview-boot.e2e.ts | 4 ++-- apps/web/tests/sessionless-header.e2e.ts | 2 +- apps/web/tests/startup-rpc-budget.e2e.ts | 2 +- apps/web/tests/support.ts | 4 ++-- .../ui-conversation/src/client/locales.ts | 2 ++ .../client/skeleton/ConversationContent.tsx | 2 +- .../tests/skeleton.client.spec.tsx | 1 + snapshots/web/default-workspace/snapshot.yml | 2 ++ .../workspace.expected/.empty | 1 + 11 files changed, 37 insertions(+), 10 deletions(-) create mode 100644 apps/web/tests/expected/default-workspace/preparing.expected.md create mode 100644 snapshots/web/default-workspace/workspace.expected/.empty diff --git a/apps/web/tests/default-workspace.e2e.ts b/apps/web/tests/default-workspace.e2e.ts index 00bec9de3c..265fa715b2 100644 --- a/apps/web/tests/default-workspace.e2e.ts +++ b/apps/web/tests/default-workspace.e2e.ts @@ -3,9 +3,9 @@ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { chromium } from 'playwright' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { - captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, + assertFinalWorkspaceSnapshot, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, selectedSessionFixture, watchConsole, webSnapshotMode, } from './scaffold.ts' import { newEnglishPage, saveFailureShot } from './support.ts' @@ -13,6 +13,7 @@ import { newEnglishPage, saveFailureShot } from './support.ts' const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url)) const EXPECTED = fileURLToPath(new URL('../../../snapshots/web/default-workspace/ui.expected.md', import.meta.url)) const FAILURE_EXPECTED = fileURLToPath(new URL('./expected/default-workspace/failure.expected.md', import.meta.url)) +const PREPARING_EXPECTED = fileURLToPath(new URL('./expected/default-workspace/preparing.expected.md', import.meta.url)) const MODE = webSnapshotMode() describe.skipIf(MODE === 'record')('web e2e: default Workspace', () => { @@ -38,7 +39,19 @@ describe.skipIf(MODE === 'record')('web e2e: default Workspace', () => { expect(scaffold.ctx.workspaceRegistry.list()).toEqual([]) expect(scaffold.ctx.sessions.list()).toEqual([]) const settled = scaffold.whenTurnSettled() - await input.press('Enter') + const preparing = Promise.withResolvers() + const initialize = scaffold.ctx.workspaceRegistry.initializeDefault.bind(scaffold.ctx.workspaceRegistry) + vi.spyOn(scaffold.ctx.workspaceRegistry, 'initializeDefault').mockImplementationOnce(async (resolve) => { + await preparing.promise + return initialize(resolve) + }) + try { + await input.press('Enter') + await page.getByRole('textbox', { name: 'Preparing workspace…', exact: true }).waitFor() + await compareOrRefreshGolden(PREPARING_EXPECTED, await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), MODE) + } finally { + preparing.resolve(undefined) + } const sessionId = await settled const workspace = scaffold.ctx.workspaceRegistry.list()[0]! expect(workspace.title).toBe('Default workspace') @@ -46,6 +59,7 @@ describe.skipIf(MODE === 'record')('web e2e: default Workspace', () => { expect((await stat(workspace.path)).isDirectory()).toBe(true) expect(workspace.sessionIds).toContain(sessionId) expect(scaffold.ctx.sessions.get(sessionId)?.header.cwd).toBe(workspace.path) + await assertFinalWorkspaceSnapshot(fileURLToPath(new URL('../../../snapshots/web/default-workspace', import.meta.url)), workspace.path) await page.getByText('DONE', { exact: true }).waitFor() await compareOrRefreshGolden(EXPECTED, await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), MODE) await page.reload() diff --git a/apps/web/tests/expected/default-workspace/preparing.expected.md b/apps/web/tests/expected/default-workspace/preparing.expected.md new file mode 100644 index 0000000000..09e86fbb6e --- /dev/null +++ b/apps/web/tests/expected/default-workspace/preparing.expected.md @@ -0,0 +1,7 @@ +- banner +- text: Into the Unknown Preview +- button "Choose workspace" +- button "Standard mode" +- textbox "Preparing workspace…" [disabled] +- button "Add files or run commands" [disabled] +- button "Send message" [disabled] diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index bde54fe69e..4797a6e4bf 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -297,7 +297,7 @@ async function bootPreview(origin: string, browser: Browser): Promise { expect(await page.getByText('Empty environment', { exact: true }).count()).toBe(1) expect(await page.getByText('WebFS directory', { exact: true }).count()).toBe(1) expect(await page.locator('input[name="preview-source"][value="webfs"]').isDisabled()).toBe(true) - expect(await page.getByRole('textbox', { name: 'Choose workspace' }).count()).toBe(0) + expect(await page.getByRole('button', { name: 'Choose workspace' }).count()).toBe(0) await compareOrRefreshGolden( SOURCE_CHOOSER_EXPECTED, await captureStableAria(page, '[data-preview-source-card]', '/__preview_no_workspace__'), @@ -471,7 +471,7 @@ async function bootEmptyPreview(origin: string, browser: Browser): Promise ) expect(bootLine).toContain(`image lowering=${WRAPPER_CONTRACT}`) expect(bootLine).toContain('data overlays=0') - await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: HERO_TIMEOUT_MS }) + await page.getByRole('button', { name: 'Choose workspace' }).waitFor({ timeout: HERO_TIMEOUT_MS }) const sessionCount = await page.evaluate(async () => { const transport = (globalThis as typeof globalThis & { __DSH_TRANSPORT__?: { fetch(input: string, init: RequestInit): Promise } diff --git a/apps/web/tests/sessionless-header.e2e.ts b/apps/web/tests/sessionless-header.e2e.ts index b2c1a85fb0..1de5dfe14f 100644 --- a/apps/web/tests/sessionless-header.e2e.ts +++ b/apps/web/tests/sessionless-header.e2e.ts @@ -30,7 +30,7 @@ describe('navigation without a selected Session', () => { } const tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await page.getByRole('textbox', { name: 'Choose workspace', exact: true }).waitFor() + await page.getByRole('button', { name: 'Choose workspace', exact: true }).waitFor() const sessionHeader = page.locator('[data-slot="conversation.session.header"]') expect(await sessionHeader.count()).toBe(0) const header = page.locator('[data-slot="conversation.header"] > header') diff --git a/apps/web/tests/startup-rpc-budget.e2e.ts b/apps/web/tests/startup-rpc-budget.e2e.ts index f6655ffbf3..d8c89afcca 100644 --- a/apps/web/tests/startup-rpc-budget.e2e.ts +++ b/apps/web/tests/startup-rpc-budget.e2e.ts @@ -36,7 +36,7 @@ describe('startup RPC budget', () => { await page.goto(scaffold.authenticatedUrl) // Boot settles when the workspace picker is interactive; the trailing wait // absorbs the first-connection reset wave the budget must include. - await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: 30_000 }) + await page.getByRole('button', { name: 'Choose workspace' }).waitFor({ timeout: 30_000 }) await page.waitForTimeout(3000) const describeCount = calls.filter(method => method === 'settings/describe').length expect(describeCount, `startup /api calls:\n${calls.join('\n')}`).toBe(DESCRIBE_BUDGET) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 884d648e8e..082f5019ae 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -122,7 +122,7 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo await pathInput.fill(join(root, name)) await pathInput.press('Enter') await dialog.getByRole('button', { name: 'Open', exact: true }).click() - await page.getByRole('treeitem', { name: 'New Session', exact: true }).waitFor({ timeout: 15_000 }) + await page.locator('[data-slot="conversation.session.header"]').waitFor({ state: 'attached', timeout: 15_000 }) await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]') .waitFor({ timeout: 15_000 }) } @@ -146,7 +146,7 @@ export async function connectFreshWorkspaceZh(page: Page, root: string, name = ' await pathInput.fill(join(root, name)) await pathInput.press('Enter') await dialog.getByRole('button', { name: '打开', exact: true }).click() - await page.getByRole('treeitem', { name: '新会话', exact: true }).waitFor({ timeout: 15_000 }) + await page.locator('[data-slot="conversation.session.header"]').waitFor({ state: 'attached', timeout: 15_000 }) await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="描述你想要构建的内容, / 调用指令, @ 文件或对话"]') .waitFor({ timeout: 15_000 }) } diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index c66fe10a8a..ecf2141f1d 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -22,6 +22,7 @@ export const zh = { 'placeholder.parentOffline': '父会话已离线,无法继续发送;仍可停止当前运行', 'placeholder.hero': '描述你想要构建的内容, / 调用指令, @ 文件或对话', 'placeholder.workspace': '选择一个工作区开始', + 'placeholder.preparing': '正在准备工作区…', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息', 'input.commands': '添加文件或调用指令', 'input.file': '文件', @@ -377,6 +378,7 @@ export const en = { 'placeholder.parentOffline': 'Parent session offline; sending is unavailable but you can still stop the run', 'placeholder.hero': 'Describe what you want to build, / commands, @ files or sessions', 'placeholder.workspace': 'Choose a workspace to start', + 'placeholder.preparing': 'Preparing workspace…', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages', 'input.commands': 'Add files or run commands', 'input.file': 'File', diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx index bf6456291f..82ea0e533d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx @@ -143,7 +143,7 @@ export function ConversationContent(props: ConversationContentProps) { ...(inert ? { disabled: true, - placeholder: t('placeholder.workspace'), + placeholder: t(firstDraft.busy ? 'placeholder.preparing' : 'placeholder.workspace'), workspacePickerOpen: pickerOpen, ...(needsWorkspace ? { onRequestWorkspace: () => { setPickerOpen(true) } } : {}), } diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 25f7002ea4..619e3fddff 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -416,6 +416,7 @@ describe('ConversationRoot resident composer', () => { }) const box = b.view.getByRole('textbox') expect(box.getAttribute('aria-disabled')).toBe('true') + expect(box.getAttribute('aria-label')).toBe('正在准备工作区…') expect(box.getAttribute('aria-haspopup')).toBeNull() fireEvent.keyDown(box, { key: 'Enter' }) expect(b.sink).not.toHaveBeenCalled() diff --git a/snapshots/web/default-workspace/snapshot.yml b/snapshots/web/default-workspace/snapshot.yml index d1eed14d70..b9efb8a8b7 100644 --- a/snapshots/web/default-workspace/snapshot.yml +++ b/snapshots/web/default-workspace/snapshot.yml @@ -3,6 +3,8 @@ scenario: default-workspace profile: web composition: web-default recording: authored +workspace: + final: true header: class: web-default session: diff --git a/snapshots/web/default-workspace/workspace.expected/.empty b/snapshots/web/default-workspace/workspace.expected/.empty new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/snapshots/web/default-workspace/workspace.expected/.empty @@ -0,0 +1 @@ + From 8e313eaa306b38c35e57a9fd5a4d74174c0360b6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sun, 20 Sep 2026 15:58:33 +0800 Subject: [PATCH 06/60] test(workspace): cover first-use configuration and preset failures --- .../api/workspace-controller/src/index.ts | 9 +++++--- .../tests/workspace-controller.host.spec.ts | 12 ++++++++-- .../tests/apply.client.spec.ts | 19 ++++++++++++---- .../tests/runtime.client.spec.tsx | 22 +++++++++++++++++++ 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/packages/api/workspace-controller/src/index.ts b/packages/api/workspace-controller/src/index.ts index 2281273e1e..966a5e9fb9 100644 --- a/packages/api/workspace-controller/src/index.ts +++ b/packages/api/workspace-controller/src/index.ts @@ -38,6 +38,9 @@ export interface Config { documentsLookupTimeoutMs?: number } +/** Directory policy after schema defaults have been applied. */ +type ResolvedConfig = Config & { documentsLookupTimeoutMs: number } + declare module '@deepseek-ai/cordis' { interface Context { /** Host Workspace business API and Remote namespace owner. */ @@ -49,12 +52,12 @@ declare module '@deepseek-ai/cordis' { export class WorkspaceController extends TypertRemoteService { static inject = ['typert', 'workspaceRegistry'] - static Config: z = z.object({ + static Config: z = z.object({ documentsDirectory: z.string(), documentsLookupTimeoutMs: z.natural().min(1).default(10_000), }) - private readonly config: Config + private readonly config: ResolvedConfig private readonly commands: WorkspaceCommands private readonly feed: WorkspaceFeed @@ -96,7 +99,7 @@ export class WorkspaceController extends TypertRemoteService { throw new RemoteError('gateway/bad-request', 'default Workspace requires a directory name and non-blank title', {}) } const workspace = await this.ctx.workspaceRegistry.initializeDefault(async () => { - const timeout = AbortSignal.timeout(this.config.documentsLookupTimeoutMs as number) + const timeout = AbortSignal.timeout(this.config.documentsLookupTimeoutMs) const path = await defaultWorkspaceDirectory( directoryName, this.config.documentsDirectory, AbortSignal.any([signal, timeout]), ) diff --git a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts index ecaeb9a248..39cd085879 100644 --- a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts +++ b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts @@ -41,7 +41,7 @@ function deferred(): Deferred { return { promise, resolve } } -async function harness() { +async function harness(options: { systemDocuments?: boolean } = {}) { const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-workspace-controller-'))) tempDirs.push(root) const ctx = new Context() @@ -59,7 +59,7 @@ async function harness() { lookups: { configure: () => dispose }, contexts: { configureHost: () => dispose }, } as never) - const controller = new WorkspaceController(ctx, { documentsDirectory: root }) + const controller = new WorkspaceController(ctx, options.systemDocuments === true ? {} : { documentsDirectory: root }) return { controller, ctx, root, storageDomain } } @@ -418,6 +418,14 @@ describe('WorkspaceController follow', () => { }) describe('first-use Remote', () => { + it('reuses an initialized Workspace without looking up system Documents', async () => { + const { controller, ctx, root } = await harness({ systemDocuments: true }) + const workspace = await ctx.workspaceRegistry.initializeDefault(async () => ({ path: root, title: 'Existing default' })) + const signal = AbortSignal.abort() + await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, signal)) + .resolves.toMatchObject({ workspace: { workspaceId: workspace!.id, path: root, title: 'Existing default' } }) + }) + it.each(['', ' ', '.', '..', '../outside', 'nested/name', 'nested\\name', 'C:outside', 'name\0', '.. ', 'tail.'])( 'rejects invalid directory name %j before invoking the registry', async (directoryName) => { const { controller, ctx } = await harness() diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index ea312699ce..378b40006f 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -6,7 +6,7 @@ */ import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' @@ -76,6 +76,7 @@ async function bench(options: { failSettingsUpdate?: boolean selectGate?: Promise settingsRosterGate?: Promise + refuseSelect?: boolean } = {}) { const ctx = new Context() // The host's answer, mutable so a spec can move the default the way the @@ -153,7 +154,9 @@ async function bench(options: { deletePreset: () => Promise.resolve({ ok: true as const, value: undefined }), select: (_agentId: SessionId, agentPreset: string) => { calls.push(`select:${agentPreset}`) - return Promise.resolve(options.selectGate).then(() => ({ ok: true as const, value: agentPreset })) + return Promise.resolve(options.selectGate).then(() => options.refuseSelect === true + ? { ok: false as const, error: new RemoteError('gateway/internal', 'preset refused', {}) } + : { ok: true as const, value: agentPreset }) }, } ctx.provide('remote.agentPresets', agentPresets as never) @@ -715,8 +718,10 @@ describe('ui-agent-preset apply', () => { conversation() }) - it('applies the staged choice to the blank session the flow lands on', async () => { - const { ctx, slots, calls } = await bench() + it.each([false, true])('awaits the staged choice before first send and propagates refusal (%s)', async (refuseSelect) => { + const selection = Promise.withResolvers() + const { ctx, slots, calls } = await bench({ refuseSelect, selectGate: selection.promise }) + onTestFinished(async () => { selection.resolve(undefined); await ctx.fiber.dispose() }) declareRoot(slots) declareConversation(slots) ctx.provide('conversation', {} as never) @@ -736,6 +741,8 @@ describe('ui-agent-preset apply', () => { .inject as unknown as (sessionId?: SessionId) => AgentPresetSeatInjected const chip = injectSeat() + await ctx.serial('conversation/prepare-first-send', SessionId('absent')) + await chip.load() // Picked on the hero screen, where there is no session yet. await chip.select('minimal') @@ -747,6 +754,10 @@ describe('ui-agent-preset apply', () => { } sessions.notify() const bound = injectSeat(SessionId('s1')) + const preparing = ctx.serial('conversation/prepare-first-send', SessionId('s1')) + const settled = refuseSelect ? expect(preparing).rejects.toThrow('preset refused') : expect(preparing).resolves.toBeUndefined() + selection.resolve(undefined) + await settled await ctx.serial('conversation/prepare-first-send', SessionId('s1')) await bound.load() diff --git a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx index 7d29ba7f72..aeff2554d1 100644 --- a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx +++ b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx @@ -557,6 +557,28 @@ describe('workspaces', () => { expect(view.container.textContent).toContain('ws:pending') await runtime.dispose() }) + it('requires an explicit default Workspace fixture and forwards its request and lifetime', async () => { + const runtime = await SlotTestRuntime.create() + try { + const request = { directoryName: 'default-workspace', title: 'Default workspace' } + const signal = new AbortController().signal + await expect(runtime.workspaces.initializeDefault(request, signal)).rejects.toThrow('requires a Workspace fixture') + const workspace = { + workspaceId: 'default' as WorkspaceId, title: request.title, path: '/default', sessionIds: [], + createdAt: '2026-09-20T00:00:00Z', updatedAt: '2026-09-20T00:00:00Z', + } + const initialize = vi.fn(async () => workspace) + runtime.workspaces.stub('initializeDefault', initialize) + await expect(runtime.workspaces.initializeDefault(request, signal)).resolves.toBe(workspace) + expect(initialize).toHaveBeenCalledWith(request, signal) + expect(runtime.workspaces.calls).toEqual([ + { method: 'initializeDefault', args: [request, signal] }, + { method: 'initializeDefault', args: [request, signal] }, + ]) + } finally { + await runtime.dispose() + } + }) }) describe('feature mount and disposal', () => { From d4d03b7c41ad12ea07254ca1ca9c170a476f8517 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sun, 20 Sep 2026 15:58:53 +0800 Subject: [PATCH 07/60] docs(workspace): clarify default directory lookup and cancellation --- .../feature/2026-09-20-default-workspace.i18n.yaml | 4 ++-- .../notes/implemented/feature/2026-09-20-default-workspace.md | 1 + .../implemented/feature/2026-09-20-default-workspace.zh.md | 1 + docs/subsystems/workspace.i18n.yaml | 4 ++-- docs/subsystems/workspace.md | 1 + docs/subsystems/workspace.zh.md | 1 + packages/api/workspace-controller/README.i18n.yaml | 4 ++-- packages/api/workspace-controller/README.md | 4 +++- packages/api/workspace-controller/README.zh.md | 4 +++- packages/api/workspace-controller/src/types.ts | 2 +- packages/extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/workspace/workspace/README.i18n.yaml | 4 ++-- packages/workspace/workspace/README.md | 2 +- packages/workspace/workspace/README.zh.md | 2 +- packages/workspace/workspace/src/index.ts | 1 + 15 files changed, 23 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-20-default-workspace.i18n.yaml b/.agents/notes/implemented/feature/2026-09-20-default-workspace.i18n.yaml index cdb2673df8..a3ed2bee71 100644 --- a/.agents/notes/implemented/feature/2026-09-20-default-workspace.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-20-default-workspace.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 .agents/notes/implemented/feature/2026-09-20-default-workspace.md -2026-09-20-default-workspace.md: d2a32d47a407dcf211c1cad8bb98f29847a77a3b -2026-09-20-default-workspace.zh.md: 6b0ec542da6e9e4fe76fb56b6562edea9e5777f4 +2026-09-20-default-workspace.md: 376b2eaf7ac26c13e7483838700c88151b44135d +2026-09-20-default-workspace.zh.md: e16c5d47c6e1bceef293089edf882162812dfa1b diff --git a/.agents/notes/implemented/feature/2026-09-20-default-workspace.md b/.agents/notes/implemented/feature/2026-09-20-default-workspace.md index d2a32d47a4..376b2eaf7a 100644 --- a/.agents/notes/implemented/feature/2026-09-20-default-workspace.md +++ b/.agents/notes/implemented/feature/2026-09-20-default-workspace.md @@ -21,6 +21,7 @@ Creation failure preserves the draft and offers the existing folder picker. A su ## Alternatives considered - Creating on application startup would write directories for users who never send a message. +- Falling back to `/Documents` when system lookup is unavailable would choose an unverified Documents location; deployments that need another directory use the explicit Host override. - Inferring first use from visible sidebar rows would ignore archived, hidden, and cwd-less Sessions. - Using the localized path as the initialization marker would allow language changes or deletion to create another default. - Changing a Session's cwd after creation would change the meaning of its recorded tools and attachments. diff --git a/.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md b/.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md index 6b0ec542da..e16c5d47c6 100644 --- a/.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md +++ b/.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md @@ -21,6 +21,7 @@ Client 在发送时按其语言解析初始目录名和标题。Host 控制器 ## Alternatives considered - 在应用启动时创建,会为从未发送消息的用户写入目录。 +- 系统查询不可用时回退到 `/Documents`,会选择未经确认的 Documents 位置;需要其他目录的部署使用显式 Host 覆盖配置。 - 根据侧栏可见行推断首次使用,会忽略已归档、隐藏和无 cwd 的 Session。 - 将本地化路径作为初始化标记,会允许切换语言或删除后再次创建默认工作区。 - 在 Session 创建后更改 cwd,会改变其已记录工具和附件的含义。 diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index a7a3e3bb91..f9bef3d306 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.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/workspace.md -workspace.md: b68acd965b55df462a0751d0b7e3230c82a62172 -workspace.zh.md: 074f59438eb16a4ffb1423540c3e1868857ae40f +workspace.md: 43a631ad0b04dd272436e272c931c2a16b7bbe88 +workspace.zh.md: c4571083f3e445828b4b3b41a27ed8abee9ee04d diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index b68acd965b..43a631ad0b 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -485,6 +485,7 @@ async create(path: string, title?: string): Promise * @param resolveDirectory - resolve the absolute directory and initial title; * called only for eligible creation, inside the registry mutation queue. * Missing directories are created recursively before registration. + * After resolution, caller cancellation does not roll back creation or registration. * @returns the initialized Workspace, or undefined when automatic creation is ineligible. */ initializeDefault(resolveDirectory: () => Promise<{ path: string; title: string }>): Promise diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index 074f59438e..c4571083f3 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -485,6 +485,7 @@ async create(path: string, title?: string): Promise * @param resolveDirectory - resolve the absolute directory and initial title; * called only for eligible creation, inside the registry mutation queue. * Missing directories are created recursively before registration. + * After resolution, caller cancellation does not roll back creation or registration. * @returns the initialized Workspace, or undefined when automatic creation is ineligible. */ initializeDefault(resolveDirectory: () => Promise<{ path: string; title: string }>): Promise diff --git a/packages/api/workspace-controller/README.i18n.yaml b/packages/api/workspace-controller/README.i18n.yaml index e7bc836965..6300d2f2e4 100644 --- a/packages/api/workspace-controller/README.i18n.yaml +++ b/packages/api/workspace-controller/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/api/workspace-controller/README.md -README.md: 275c51d0018dda5b2209cc7bece71b618e7d113d -README.zh.md: 92ca3f86f11ce77b51f834ce7c24f195095065b1 +README.md: 24bf0610689bfb857b24e7c8298b1b0494f1ca02 +README.zh.md: ca140abfdda246b62b0485e63ebfd05ab2efe6f9 diff --git a/packages/api/workspace-controller/README.md b/packages/api/workspace-controller/README.md index 275c51d001..24bf061068 100644 --- a/packages/api/workspace-controller/README.md +++ b/packages/api/workspace-controller/README.md @@ -29,7 +29,7 @@ The Client entry provides `ClientWorkspaceModel` and `createWorkspaceStateStream ### First-use Workspace -`workspace.initializeDefault({ directoryName, title })` returns the durable default Workspace; the Client service exposes the same request as `workspaces.initializeDefault(request, signal?)`. The [Conversation Client](../../client/ui-conversation/README.md) resolves these names from its send-time language. The Host places the directory under its account's `/deepseek-harness`, including on remote Web hosts. The directory name must be one non-blank segment without separators, colon, NUL, surrounding whitespace, or a trailing dot; the title must be non-blank. Linux system lookup requires `xdg-user-dir` with an enabled Documents directory. +`workspace.initializeDefault({ directoryName, title })` returns the durable default Workspace; the Client service exposes the same request as `workspaces.initializeDefault(request, signal?)`. The [Conversation Client](../../client/ui-conversation/README.md) resolves these names from its send-time language. The Host places the directory under its account's `/deepseek-harness`, including on remote Web hosts. The directory name must be one non-blank segment without separators, colon, NUL, surrounding whitespace, or a trailing dot; the title must be non-blank. OS filename restrictions also apply. Linux system lookup requires `xdg-user-dir` with an enabled Documents directory; hosts without it must configure `documentsDirectory` or use the folder picker. The [Workspace registry](../../workspace/workspace/README.md#first-use-workspace) owns eligibility, directory creation, and durable initialization. An existing default Workspace is returned without another Documents lookup; request names do not rename it. Invalid names or ineligible first use reject with `gateway/bad-request`; lookup and creation failures use standard Remote error handling. Initialization creates no Session and sends no message. @@ -38,6 +38,8 @@ The [Workspace registry](../../workspace/workspace/README.md#first-use-workspace | `documentsDirectory` | System Documents directory | Fully qualified Host directory override | | `documentsLookupTimeoutMs` | `10000` | Positive maximum duration of OS directory lookup, in milliseconds | +Documents lookup holds the registry mutation queue, so other Workspace mutations, including registration of a picked directory, can wait up to `documentsLookupTimeoutMs`. Cancellation can stop the lookup; after resolution succeeds, it does not roll back creation or registration. + ----- diff --git a/packages/api/workspace-controller/README.zh.md b/packages/api/workspace-controller/README.zh.md index 92ca3f86f1..ca140abfdd 100644 --- a/packages/api/workspace-controller/README.zh.md +++ b/packages/api/workspace-controller/README.zh.md @@ -29,7 +29,7 @@ Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。 ### 首次使用工作区 -`workspace.initializeDefault({ directoryName, title })` 返回持久化的默认工作区;Client service 通过 `workspaces.initializeDefault(request, signal?)` 提供同一请求。[Conversation Client](../../client/ui-conversation/README.zh.md)按发送时的语言解析这些名称。Host 将目录放在其账户的 `/deepseek-harness` 下,远程 Web Host 也遵循此规则。目录名必须是非空的单个片段,不能含分隔符、冒号、NUL、首尾空白或末尾句点;标题不能为空。Linux 系统查询要求存在 `xdg-user-dir` 且启用了 Documents 目录。 +`workspace.initializeDefault({ directoryName, title })` 返回持久化的默认工作区;Client service 通过 `workspaces.initializeDefault(request, signal?)` 提供同一请求。[Conversation Client](../../client/ui-conversation/README.zh.md)按发送时的语言解析这些名称。Host 将目录放在其账户的 `/deepseek-harness` 下,远程 Web Host 也遵循此规则。目录名必须是非空的单个片段,不能含分隔符、冒号、NUL、首尾空白或末尾句点;标题不能为空。操作系统的文件名限制同样适用。Linux 系统查询要求存在 `xdg-user-dir` 且启用了 Documents 目录;不具备该条件的 Host 必须配置 `documentsDirectory` 或使用文件夹选择器。 [Workspace 注册表](../../workspace/workspace/README.zh.md#first-use-workspace)负责资格判断、目录创建和持久化初始化。已有默认工作区直接返回,不再查询 Documents;请求中的名称不会将其重命名。名称无效或不满足首次使用条件时,以 `gateway/bad-request` 拒绝;查询和创建失败遵循标准 Remote 错误处理。初始化不创建 Session,也不发送消息。 @@ -38,6 +38,8 @@ Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。 | `documentsDirectory` | 系统 Documents 目录 | 完全限定的 Host 目录覆盖值 | | `documentsLookupTimeoutMs` | `10000` | 操作系统目录查询的正数最大时长,单位为毫秒 | +Documents 查询占用注册表变更队列,因此其他 Workspace 变更(包括登记已选目录)最多可能等待 `documentsLookupTimeoutMs`。取消可以停止查询;解析成功后,取消不会回滚创建或登记。 + ----- diff --git a/packages/api/workspace-controller/src/types.ts b/packages/api/workspace-controller/src/types.ts index e69c2b8c91..97188d9f23 100644 --- a/packages/api/workspace-controller/src/types.ts +++ b/packages/api/workspace-controller/src/types.ts @@ -56,7 +56,7 @@ export interface WorkspaceCreateRequest { /** Names chosen by the first-use Client before default Workspace initialization. */ export interface WorkspaceInitializeDefaultRequest { - /** Single non-blank name; no separators, colon, NUL, surrounding whitespace, or trailing dot. */ + /** Host rejects blank names, separators, colon, NUL, surrounding whitespace, and trailing dots; OS filename restrictions also apply. */ readonly directoryName: string /** Initial display title, retained across language changes. */ readonly title: string diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index eac1fcac40..b89f2f3ada 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -3376,7 +3376,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { signature: 'initializeDefault(resolveDirectory: () => Promise<{ path: string; title: string }>): Promise', description: 'Initialize the default Workspace only while both the registry and Session history are empty. Repeated requests reuse its durable identity; deleting that registration permanently disables automatic creation.', - parameters: [{ name: 'resolveDirectory', description: 'resolve the absolute directory and initial title; called only for eligible creation, inside the registry mutation queue. Missing directories are created recursively before registration.' }], + parameters: [{ name: 'resolveDirectory', description: 'resolve the absolute directory and initial title; called only for eligible creation, inside the registry mutation queue. Missing directories are created recursively before registration. After resolution, caller cancellation does not roll back creation or registration.' }], returns: 'the initialized Workspace, or undefined when automatic creation is ineligible.', }, { diff --git a/packages/workspace/workspace/README.i18n.yaml b/packages/workspace/workspace/README.i18n.yaml index 43e9c7d159..7f50ab3f55 100644 --- a/packages/workspace/workspace/README.i18n.yaml +++ b/packages/workspace/workspace/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/workspace/workspace/README.md -README.md: f11d6496c04cf346c21f869e00ec5fb5526008db -README.zh.md: 3d458e13157bda612c3d33d0e7bfb57fa914e9b3 +README.md: 897aca8eb725bc9c3e0808e394e5c976886dd877 +README.zh.md: dc7a81340c9b134110975e700d43d5569fa6f03d diff --git a/packages/workspace/workspace/README.md b/packages/workspace/workspace/README.md index f11d6496c0..897aca8eb7 100644 --- a/packages/workspace/workspace/README.md +++ b/packages/workspace/workspace/README.md @@ -66,7 +66,7 @@ ctx.workspaceRegistry.list() // shows the project, newest first The directory resolver runs inside the mutation queue only when creation is eligible. It returns an absolute path and initial title; the registry creates missing parent directories, canonicalizes the path, rechecks Session history, and commits the Workspace with its initialization marker. An existing directory is reused; a file conflict or directory failure rejects initialization. The [Host controller](../../api/workspace-controller/README.md#first-use-workspace) supplies the Documents path policy. -The first successful registration records its identity durably. Repeated calls return it without resolving a directory again; renaming keeps that identity, and deleting its registration does not permit another automatic creation. Directory or registration failure leaves initialization unset for retry. Directories created before a later failure remain on disk. The [first-use decision](../../../.agents/notes/implemented/feature/2026-09-20-default-workspace.md) explains this lifetime. +The first successful registration records its identity durably. Repeated calls return it without resolving a directory again; renaming keeps that identity, and deleting its registration does not permit another automatic creation. Directory or registration failure leaves initialization unset for retry. Directories created before a later failure remain on disk. Once directory resolution succeeds, caller cancellation does not roll back directory creation or registration. The [first-use decision](../../../.agents/notes/implemented/feature/2026-09-20-default-workspace.md) explains this lifetime. ### Grouping sessions under a project diff --git a/packages/workspace/workspace/README.zh.md b/packages/workspace/workspace/README.zh.md index 3d458e1315..dc7a81340c 100644 --- a/packages/workspace/workspace/README.zh.md +++ b/packages/workspace/workspace/README.zh.md @@ -66,7 +66,7 @@ ctx.workspaceRegistry.list() // shows the project, newest first 目录解析器仅在允许创建时于变更队列内运行。它返回绝对路径和初始标题;注册表创建缺失的父目录、规范化路径、重新检查 Session 历史,再一起提交 Workspace 和初始化标记。已存在的目录直接复用;文件冲突或目录操作失败时拒绝初始化。[Host 控制器](../../api/workspace-controller/README.zh.md#first-use-workspace)提供 Documents 路径策略。 -首次成功登记会持久保存工作区身份。重复调用直接返回它,不再解析目录;改名保留该身份,删除登记也不会允许再次自动创建。目录或登记失败时,初始化状态保持未设置,可以重试。后续步骤失败前已创建的目录会保留在磁盘上。[首次使用决策](../../../.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md)说明这一生命周期。 +首次成功登记会持久保存工作区身份。重复调用直接返回它,不再解析目录;改名保留该身份,删除登记也不会允许再次自动创建。目录或登记失败时,初始化状态保持未设置,可以重试。后续步骤失败前已创建的目录会保留在磁盘上。目录解析成功后,调用方取消操作不会回滚目录创建或登记。[首次使用决策](../../../.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md)说明这一生命周期。 ### 将会话归入项目 diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index 6cb179022a..d0f3d3b8a5 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -182,6 +182,7 @@ export class WorkspaceRegistry extends Service { * @param resolveDirectory - resolve the absolute directory and initial title; * called only for eligible creation, inside the registry mutation queue. * Missing directories are created recursively before registration. + * After resolution, caller cancellation does not roll back creation or registration. * @returns the initialized Workspace, or undefined when automatic creation is ineligible. */ initializeDefault(resolveDirectory: () => Promise<{ path: string; title: string }>): Promise { From 4d2e420ef852578770b9fb72dddcb2dcba85a236 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sun, 20 Sep 2026 17:19:54 +0800 Subject: [PATCH 08/60] refactor(workspace): initialize the default workspace on startup --- ...ession-scope-and-provide-channel.i18n.yaml | 4 +- ...lient-session-scope-and-provide-channel.md | 9 +- ...nt-session-scope-and-provide-channel.zh.md | 9 +- .../2026-09-20-default-workspace.i18n.yaml | 4 +- .../feature/2026-09-20-default-workspace.md | 12 +- .../2026-09-20-default-workspace.zh.md | 12 +- apps/web/tests/README.i18n.yaml | 4 +- apps/web/tests/README.md | 2 + apps/web/tests/README.zh.md | 2 + apps/web/tests/default-workspace.e2e.ts | 199 +++++++++--------- .../default-workspace/preparing.expected.md | 7 - apps/web/tests/scaffold.ts | 8 +- docs/subsystems/workspace.i18n.yaml | 4 +- docs/subsystems/workspace.md | 6 +- docs/subsystems/workspace.zh.md | 6 +- .../api/workspace-controller/README.i18n.yaml | 4 +- packages/api/workspace-controller/README.md | 4 +- .../api/workspace-controller/README.zh.md | 4 +- .../workspace-controller/src/client/model.ts | 6 +- .../src/client/service.ts | 8 +- .../api/workspace-controller/src/index.ts | 11 +- .../tests/model.client.spec.ts | 4 + .../tests/transport.client.spec.ts | 12 ++ .../tests/workspace-controller.host.spec.ts | 10 +- .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 2 - packages/client/ui-agent-preset/README.zh.md | 2 - .../ui-agent-preset/src/client/index.ts | 8 - .../ui-agent-preset/src/client/seat-store.ts | 13 +- .../tests/apply.client.spec.ts | 52 +---- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 6 +- packages/client/ui-conversation/README.zh.md | 6 +- .../ui-conversation/src/client/apply.ts | 43 ++-- .../src/client/contract/input.ts | 6 - .../src/client/contract/slots.ts | 13 +- .../ui-conversation/src/client/index.ts | 8 - .../src/client/input/facade.ts | 4 +- .../src/client/input/first-draft.ts | 82 -------- .../ui-conversation/src/client/locales.ts | 10 - .../client/skeleton/ConversationContent.tsx | 42 ++-- .../src/client/skeleton/ConversationRoot.tsx | 5 +- .../src/client/skeleton/InputBar.tsx | 81 ++++--- .../tests/apply-inject.client.spec.tsx | 111 +--------- .../tests/assembly-surfaces.client.spec.tsx | 22 +- .../tests/first-draft.client.spec.ts | 141 ------------- .../tests/input-bar.client.spec.tsx | 1 - .../tests/input-matrix.client.spec.tsx | 1 - .../tests/input-scenarios.client.spec.tsx | 1 - .../tests/skeleton.client.spec.tsx | 17 -- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 4 +- packages/client/ui-workspace/README.zh.md | 4 +- .../src/client/WorkspacePicker.tsx | 48 +++-- .../ui-workspace/src/client/contract/slots.ts | 8 +- .../client/ui-workspace/src/client/index.ts | 3 +- .../client/ui-workspace/src/client/locales.ts | 8 + .../ui-workspace/src/client/navigation.ts | 56 +++-- .../tests/workspace-picker.client.spec.tsx | 35 ++- .../tests/workspaces-service.client.spec.ts | 115 +++++++--- .../extensions/tool-cordis/src/api-catalog.ts | 6 +- .../client-runtime/src/workspaces.ts | 9 +- .../tests/runtime.client.spec.tsx | 4 +- scripts/gen-cordis-catalog.ts | 1 - 64 files changed, 509 insertions(+), 832 deletions(-) delete mode 100644 apps/web/tests/expected/default-workspace/preparing.expected.md delete mode 100644 packages/client/ui-conversation/src/client/input/first-draft.ts delete mode 100644 packages/client/ui-conversation/tests/first-draft.client.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index 8790781b63..da98873480 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.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 .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: f1e83e8b38561d5edd3bf6a212aa2bd4ac5028f8 -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 6eea0af064caf976774ae2bb2992fd6651daf695 +2026-07-25-web-client-session-scope-and-provide-channel.md: 76561bc0e330b3e3a26607569ae327217d0a0b67 +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: d5d92b2ca53ba78929dd8591a6ded7028e4fed24 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index f1e83e8b38..76561bc0e3 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -26,8 +26,8 @@ The reference-owned lifetime and Provider targeting now follow [Client Session r Host-side `session.create(workspaceId)` produces Session + Agent + cwd in one piece (an atomic bundle, never split); the client side is the mirror of that birth — the instant a session row enters the list mirror, the client mints its Agent scope (actx + provide + the full input surface mounted): - Session identity is the host's true form from birth: the sessionId arrives via the `session.create` response / the `host/session-added` frame, and every client-side address (the scope tag, slot store keys, RPC addressing) uses that same id. -- A manual Workspace pick or successful [first-use default preparation](../feature/2026-09-20-default-workspace.md) establishes cwd before the client calls `session.create({workspaceId})` and receives the complete entity. -- "New Session with no workspace picked" has no Session or scope entity. The composer retains a local text draft; Session-scoped slash commands become available after connection. +- A manual Workspace pick or successful [first-use startup initialization](../feature/2026-09-20-default-workspace.md) establishes cwd before the client calls `session.create({workspaceId})` and receives the complete entity. +- "New Session with no workspace picked" is a **pure view state** (a navigation position) corresponding to no session/scope entity; until the pick, the composer is locked whole (no slash, no plain text). - A "blank session" is an ordinary materialized session with no turn yet; to Agent-scope plugins on the host (goal/plan/skill/…) it remains an ordinary Session, so slash/plan are naturally live. ### Agent scope: the actx is the sole session carrier in the client-side cordis world @@ -95,7 +95,7 @@ Slot scope is the closed set `root | session-maybe | session`: - `session-maybe` inherits the nearest `SessionProvider` binding with ADOPTION identity: an incarnation born Session-less keeps its React instance when that Provider receives its first binding, then remounts when the Provider switches generation or returns to absence. Component-local per-Session state clears when the Provider switches generation. Across a switch, only persisted Store values survive generation retirement; binding-owned sources survive only when another reference keeps that generation alive. With no binding, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. Provider-roster changes rematerialize the mounted binding without changing its identity, while the per-entry adoption bookkeeping lives in the renderer's `SessionMaybeEntry`. - `session` guarantees that `sessionId`, every hook source, and every prop exist; each strict entry's error boundary is keyed by `sessionId`, so switching sessions recreates that entry and its session store. -`conversation` is the resident `session-maybe` shell under its owning `SessionProvider`: `ConversationRoot`, HeroShell, the Workspace picker, the scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-Session → blank-Session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same Session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: before a Session exists, its injected editor retains the first-use text draft; after connection, the same InputBar instance receives the retained Session's input. The remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip. +`conversation` is the resident `session-maybe` shell under its owning `SessionProvider`: `ConversationRoot`, HeroShell, the Workspace picker, the scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-Session → blank-Session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same Session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no Session its machine faces and message actions are inert, while the whole dashed card opens the existing Workspace picker by pointer and its read-only textarea does the same through Enter or Space. The same instance — textarea included — goes live when a binding appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip. Blank Sessions retain the header's leading and corner slots so navigation controls, including the right-sidebar opener, are available before the first message. Title, actions, utilities, and View tabs remain hidden in the blank phase. The header still requires a selected Session; the Files and Terminal entries use that Session's workspace and execution services without requiring a recorded Turn. @@ -136,5 +136,6 @@ Blank Sessions retain the header's leading and corner slots so navigation contro - Plugins gain session context isomorphic to the host's: per-session state hangs on the actx and mounts/tears down in one piece with the scope fiber, making leaks structurally impossible; two-session isolation is structurally guaranteed by the scope filter. - The client object layer converges to a wire mirror: session identity, lifecycle, and capability adjudication all defer to the host entity — the input system (the next layer) always faces a session with a real Agent, and providers like slash/skill uniformly address by sessionId directly. - Blank-session governance takes zero dedicated mechanisms: state rides one derived bit, visibility rides the unified list projection (only the current blank shows, as `New Session`), reuse acquires the persisted writer, and the ordinary ceiling rides same-Workspace reuse. The accepted/running display memory that guards the conversion is Client-local. -- The cost: the id→ctx handoff discipline and provide's Concurrent discipline are conventions rather than type-enforced, pinned by review and tests. Session-scoped input remains unavailable until a Session exists; the [resident conversation shell](../../../../packages/client/ui-conversation/README.md) owns the local first-use draft during that interval. + +- The cost: the id→ctx handoff discipline and provide's Concurrent discipline are conventions rather than type-enforced, pinned by review and tests. The single state axis still withholds machine faces until a Session exists; the [resident conversation shell](../../../../packages/client/ui-conversation/README.md) routes activation to the Workspace picker during that interval. - Known gaps: approval/question recovery across prune (TODO); model selection returns in live-mutation shape (the host `selectModel` trio is ready-made, its client consumer not yet built). diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 6eea0af064..d5d92b2ca5 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -26,8 +26,8 @@ web client 只有一张全局会话面:slot 全部从根上下文渲染,插 host 侧 `session.create(workspaceId)` 一体产出 Session + Agent + cwd(作为不可拆分的原子整体);client 侧就是这次出生的镜像——会话行进入 list mirror 的瞬间,client 为它铸 Agent scope(actx + provide + 输入面全套挂上): - 会话身份自出生即为 host 真身:sessionId 由 `session.create` 响应 / `host/session-added` 帧带来,client 侧一切寻址(scope tag、slot store 键、RPC 地址)用的都是同一个 id。 -- 手动选择 Workspace 或成功[准备首次使用默认工作区](../feature/2026-09-20-default-workspace.zh.md)先确定 cwd,client 随后调用 `session.create({workspaceId})` 并取得完整实体。 -- 「New Session 且未选 workspace」没有 Session 或 scope 实体。Composer 保留本地文本草稿;连接后才提供 Session scope 的 slash 命令。 +- 手动选定 Workspace 或完成[首次使用的启动初始化](../feature/2026-09-20-default-workspace.zh.md) 后,cwd 已确定,client 再调 `session.create({workspaceId})`,拿到完整实体。 +- 「New Session 且未选 workspace」是**纯视图态**(一个导航位置),不对应任何会话/scope 实体;选定之前 composer 整体锁死(无 slash、无纯文本)。 - 「空会话」是尚无轮次的普通实体化会话;对 host 上的 Agent-scope 插件(goal/plan/skill(技能)/…)而言,它仍是普通 Session,因此 slash/plan 自然可用。 ### Agent scope:actx 是 client 侧 cordis 世界的唯一会话载体 @@ -95,7 +95,7 @@ slot scope 是闭集 `root | session-maybe | session`: - `session-maybe` 以**收养(adoption)身份语义**继承最近 `SessionProvider` 的 binding:空态出生的化身在该 Provider 第一次收到 binding 时保持 React 实例,此后 Provider 切换 generation 或回到空态时重挂。Provider 切换 generation 时,组件本地的逐 Session 状态会清零。切换过程中,只有持久化 Store 值能活过 generation 退休;只有另一份 reference 保活该 generation 时,binding 自有 source 才能保留。无 binding 时,`sessionId`、`useSession`/`useInput` 的结果与 `inputActions` 均可缺省。Provider roster 变化会重新物化已挂载 binding,但不改变其 identity;逐 entry 的收养记账住在 renderer 的 `SessionMaybeEntry`。 - `session` 保证 `sessionId`、所有钩子 source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key,切换会话会重建该 entry 及其会话 store。 -`conversation` 是其 owner `SessionProvider` 下的 `session-maybe` 常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无 Session → blank Session 的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 Session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:Session 存在前,注入的编辑器保存首次使用文本草稿;连接后,同一 InputBar 实例接收已持有 Session 的输入。其余输入 slot 保持严格 `session`,在此之前不派发任何内容。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。 +`conversation` 是其 owner `SessionProvider` 下的 `session-maybe` 常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无 Session → blank Session 的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 Session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 Session 时,其 machine faces 和消息动作保持惰性,整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。binding 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不派发任何内容。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。 blank Session 保留 header 的 leading 与 corner slot,让右侧栏展开入口等导航控件在首条消息之前即可使用。标题、actions、utilities 和 View tabs 在 blank phase 中继续隐藏。header 仍要求已选中的 Session;Files 与 Terminal 入口使用该 Session 的工作区和执行服务,无需已有 Turn 记录。 @@ -136,5 +136,6 @@ blank Session 保留 header 的 leading 与 corner slot,让右侧栏展开入 - 插件获得与 host 同构的会话上下文:逐会话状态挂 actx、随 scope fiber 一次拆装,泄漏结构性不可能;双会话隔离由 scope filter 结构性保证。 - client 对象层收敛为 wire 镜像:会话身份、生命周期、能力判别全部以 host 实体为准——输入体系(下一层)面对的永远是「有真 Agent 的会话」,slash/skill 等提供方一律以 sessionId 直接寻址。 - 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 `New Session` 展示),复用时获取持久化写锁,常规上限靠同 Workspace 复用。转正所依赖的受理/运行展示记忆仅保留在客户端。 -- 代价:id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住。Session 存在前,其 scope 输入仍不可用;这段时间内,[常驻会话壳](../../../../packages/client/ui-conversation/README.zh.md)负责本地首次使用草稿。 + +- 代价:id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住。单一状态轴仍会在 Session 存在前隐藏 machine face;这段时间内,[常驻会话壳](../../../../packages/client/ui-conversation/README.zh.md)会把激活操作转到 Workspace picker。 - 已知欠账:approval/question 跨 prune 恢复(TODO);模型选择以 live-mutation 形状回归(host `selectModel` 三件套现成,其 client 消费方尚未构建)。 diff --git a/.agents/notes/implemented/feature/2026-09-20-default-workspace.i18n.yaml b/.agents/notes/implemented/feature/2026-09-20-default-workspace.i18n.yaml index a3ed2bee71..89b4d5dabf 100644 --- a/.agents/notes/implemented/feature/2026-09-20-default-workspace.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-20-default-workspace.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 .agents/notes/implemented/feature/2026-09-20-default-workspace.md -2026-09-20-default-workspace.md: 376b2eaf7ac26c13e7483838700c88151b44135d -2026-09-20-default-workspace.zh.md: e16c5d47c6e1bceef293089edf882162812dfa1b +2026-09-20-default-workspace.md: 1674079403b17fb1bd984052a85975609179cad0 +2026-09-20-default-workspace.zh.md: 81a7c96efa3d8b8d4673b684b59babe339706f29 diff --git a/.agents/notes/implemented/feature/2026-09-20-default-workspace.md b/.agents/notes/implemented/feature/2026-09-20-default-workspace.md index 376b2eaf7a..1674079403 100644 --- a/.agents/notes/implemented/feature/2026-09-20-default-workspace.md +++ b/.agents/notes/implemented/feature/2026-09-20-default-workspace.md @@ -10,17 +10,17 @@ A new installation requires a directory choice before the user can send a first ## Decision -The conversation shell retains an unsent local text draft without creating a Session. Its first send asks the Host to prepare a default Workspace, then connects a real Session and submits through the ordinary composer pipeline. The first-use draft awaits staged mode selection before automatic submission. This partially supersedes the locked no-Session composer in [Session scope and provisioning](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.md); that note still owns blank-Session reuse and provisioning rationale. +Startup waits for complete Workspace and Session baselines. When both lists are empty, the Client asks the Host to prepare the default Workspace, then creates or reuses and selects its blank Session. Input stays unavailable until a Session exists and uses the ordinary composer pipeline thereafter. [Session scope and provisioning](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.md) owns blank-Session reuse and provisioning rationale. The [Workspace registry](../../../../packages/workspace/workspace/README.md#first-use-workspace) owns eligibility and directory preparation. All live Sessions, persisted headers, and archived identities count, including entries absent from the sidebar. The operation shares the registry mutation queue and rechecks Session history after directory preparation because Sessions can start independently. -The Client resolves the initial directory name and title from its language when sending. The Host controller resolves the Documents location; the registry receives a directory resolver with no locale dependency. The resolver runs inside the mutation queue only for eligible creation, so repeated requests reuse the durable Workspace without another OS lookup. A durable Workspace id records successful initialization independently of that name. Renaming, changing language, restarting, or deleting the registration cannot initialize another default. The marker commits with the registration, so a failed registration can retry. Directory contents remain subject to the existing [metadata-only deletion policy](2026-07-27-workspace-registration-deletion.md). +The Client resolves the initial directory name and title from its language at startup. The Host controller resolves the Documents location; the registry receives a directory resolver with no locale dependency. The resolver runs inside the mutation queue only for eligible creation, so repeated requests reuse the durable Workspace without another OS lookup. A durable Workspace id records successful initialization independently of that name. Renaming, changing language, restarting, or deleting the registration cannot initialize another default. The marker commits with the registration, so a failed registration can retry. Directory contents remain subject to the existing [metadata-only deletion policy](2026-07-27-workspace-registration-deletion.md). -Creation failure preserves the draft and offers the existing folder picker. A successful registration survives Session creation or prompt failure. Picking a folder after failure transfers the text without sending it; a later send remains the user's action. +Ineligible first use returns no Workspace without a failure dialog. Preparation failure offers the existing folder picker; startup does not retry on later list notifications. A successful registration survives Session creation or prompt failure. Selecting either the default or a manually picked Workspace never submits a message. ## Alternatives considered -- Creating on application startup would write directories for users who never send a message. +- Creating only when sending avoids unused directories but requires a separate local draft, transfer into a Session, and coordination of the first submission. Startup creation accepts the early filesystem effect and uses ordinary Session input throughout. - Falling back to `/Documents` when system lookup is unavailable would choose an unverified Documents location; deployments that need another directory use the explicit Host override. - Inferring first use from visible sidebar rows would ignore archived, hidden, and cwd-less Sessions. - Using the localized path as the initialization marker would allow language changes or deletion to create another default. @@ -28,6 +28,6 @@ Creation failure preserves the draft and offers the existing folder picker. A su ## Consequences -First-use typing has no Host Session or filesystem effects. Preparation remains a Host responsibility for both Desktop and remote Web clients, so remote users use the Host account's Documents location. Unavailable Documents lookup fails with the same folder-selection recovery as directory creation failure. +Opening a new installation can create a directory and blank Session before the user sends a message. Directory authorization and preparation failures can appear during startup. Preparation remains a Host responsibility for both Desktop and remote Web clients, so remote users use the Host account's Documents location. Unavailable Documents lookup fails with the same folder-selection recovery as directory creation failure. -The feature preserves [reference-owned Client Session lifetimes](../architecture/2026-09-15-client-session-references.md) and requires no agent-loop or Session-event change. Registry and client tests cover retries, hidden history, concurrency, and draft recovery; the [browser scenario](../../../../apps/web/tests/default-workspace.e2e.ts) verifies the assembled first-send and picker paths with an isolated Documents directory and recorded Session replay. +The feature preserves [reference-owned Client Session lifetimes](../architecture/2026-09-15-client-session-references.md) and requires no agent-loop or Session-event change. Registry and client tests cover retries, hidden history, concurrency, and startup cancellation; the [browser scenario](../../../../apps/web/tests/default-workspace.e2e.ts) verifies the assembled startup, reload, first-send, and picker paths with an isolated Documents directory and recorded Session replay. diff --git a/.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md b/.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md index e16c5d47c6..81a7c96efa 100644 --- a/.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md +++ b/.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md @@ -10,17 +10,17 @@ Status: implemented ## Decision -会话外壳保存未发送的本地文本草稿,不创建 Session。首次发送请求 Host 准备默认 Workspace,随后连接真实 Session,并通过常规 composer 流程提交。首次使用的草稿会在自动提交前等待暂存的模式选择完成。这部分取代[Session scope 与供给通道](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md)中无 Session 时锁定 composer 的决策;该记录仍负责 blank Session 复用与供给通道的理由。 +启动时等待完整的 Workspace 和 Session 基线。两份列表均为空时,Client 请求 Host 准备默认 Workspace,随后创建或复用并选中其空白 Session。Session 存在前不可输入,之后使用常规 composer 流程。[Session scope 与供给通道](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md)负责 blank Session 复用与供给通道的理由。 [Workspace registry](../../../../packages/workspace/workspace/README.zh.md#first-use-workspace)负责资格判断和目录准备。所有 live Session、持久化 header 和已归档身份都计入判断,包括侧栏中不可见的条目。操作共用 registry 修改队列,并在目录准备后重新检查 Session 历史,因为 Session 可以独立启动。 -Client 在发送时按其语言解析初始目录名和标题。Host 控制器解析 Documents 位置;注册表接收不依赖 locale 的目录解析器。解析器仅在允许创建时于变更队列内运行,因此重复请求直接复用持久化的 Workspace,无需再次查询操作系统目录。持久化 Workspace id 独立于名称记录初始化成功。改名、切换语言、重启或删除登记均不能再次初始化默认工作区。标记与登记一起提交,因此登记失败可以重试。目录内容仍遵循已有的[仅删除元数据策略](2026-07-27-workspace-registration-deletion.zh.md)。 +Client 在启动时按其语言解析初始目录名和标题。Host 控制器解析 Documents 位置;注册表接收不依赖 locale 的目录解析器。解析器仅在允许创建时于变更队列内运行,因此重复请求直接复用持久化的 Workspace,无需再次查询操作系统目录。持久化 Workspace id 独立于名称记录初始化成功。改名、切换语言、重启或删除登记均不能再次初始化默认工作区。标记与登记一起提交,因此登记失败可以重试。目录内容仍遵循已有的[仅删除元数据策略](2026-07-27-workspace-registration-deletion.zh.md)。 -创建失败时保留草稿,并提供现有文件夹选择器。成功的登记会在 Session 创建或提示词发送失败后保留。失败后选择文件夹只转移文本,不发送;随后发送仍由用户操作。 +不符合首次使用条件时不返回 Workspace,也不显示失败弹窗。准备失败时提供现有文件夹选择器;后续列表通知不会触发启动流程重试。成功的登记会在 Session 创建或提示词发送失败后保留。选中默认或手动选择的 Workspace 均不会提交消息。 ## Alternatives considered -- 在应用启动时创建,会为从未发送消息的用户写入目录。 +- 仅在发送时创建可以避免未使用的目录,但需要独立的本地草稿、向 Session 转移草稿及首次提交协调逻辑。启动时创建接受提前写入文件系统的副作用,始终使用普通 Session 输入。 - 系统查询不可用时回退到 `/Documents`,会选择未经确认的 Documents 位置;需要其他目录的部署使用显式 Host 覆盖配置。 - 根据侧栏可见行推断首次使用,会忽略已归档、隐藏和无 cwd 的 Session。 - 将本地化路径作为初始化标记,会允许切换语言或删除后再次创建默认工作区。 @@ -28,6 +28,6 @@ Client 在发送时按其语言解析初始目录名和标题。Host 控制器 ## Consequences -首次使用时输入文本不会产生 Host Session 或文件系统副作用。Desktop 和远程 Web 客户端均由 Host 准备目录,因此远程用户使用 Host 账户的 Documents 位置。Documents 查询不可用时,采用与目录创建失败相同的文件夹选择恢复路径。 +打开新安装的应用即可在用户发送消息前创建目录和空白 Session。目录授权与准备失败提示可能在启动期间出现。Desktop 和远程 Web 客户端均由 Host 准备目录,因此远程用户使用 Host 账户的 Documents 位置。Documents 查询不可用时,采用与目录创建失败相同的文件夹选择恢复路径。 -该功能保留[由引用持有的 Client Session 生命周期](../architecture/2026-09-15-client-session-references.zh.md),无需修改 agent-loop 或 Session 事件。Registry 和客户端测试覆盖重试、隐藏历史、并发及草稿恢复;[浏览器场景](../../../../apps/web/tests/default-workspace.e2e.ts)通过隔离的 Documents 目录和已录制 Session 回放验证完整装配下的首次发送与选择器路径。 +该功能保留[由引用持有的 Client Session 生命周期](../architecture/2026-09-15-client-session-references.zh.md),无需修改 agent-loop 或 Session 事件。Registry 和客户端测试覆盖重试、隐藏历史、并发及启动取消;[浏览器场景](../../../../apps/web/tests/default-workspace.e2e.ts)通过隔离的 Documents 目录和已录制 Session 回放验证完整装配下的启动、刷新、首次发送与选择器路径。 diff --git a/apps/web/tests/README.i18n.yaml b/apps/web/tests/README.i18n.yaml index cf707e6cb3..4d920402fa 100644 --- a/apps/web/tests/README.i18n.yaml +++ b/apps/web/tests/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/web/tests/README.md -README.md: b2fa27bb7a60ed78309bf81e77b9cd5217bbee2a -README.zh.md: 29ae2e86018257a4fc2379ea2563c8b5e5e9036f +README.md: 8b9c58ce9e4cf2f5f6978c0c6eb5517fe000d3d2 +README.zh.md: 199ae3b56166aa2ffa1c0bed9a16b844ffd7b74f diff --git a/apps/web/tests/README.md b/apps/web/tests/README.md index b2fa27bb7a..8b9c58ce9e 100644 --- a/apps/web/tests/README.md +++ b/apps/web/tests/README.md @@ -4,6 +4,8 @@ English | [中文](README.zh.md) These tests boot the real web composition in-process and drive it with a real Chromium over real HTTP. The lane's mechanics — modes, fixtures, goldens, and the deliberate composition divergences from `dsh web` — are documented in [`scaffold.ts`](scaffold.ts) and the [browser e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md). +Ordinary scenarios begin with no registered Workspace or Session and a durable marker recording a removed default Workspace, so explicit folder-selection scenarios retain control of their cwd. `launchWebScaffold({ firstUse: true })` leaves initialization eligible for startup scenarios. + ## Completion observations State-sensitive cases use Workspace, admission, attachment, and model-stream barriers to separate visible intermediate states from completed operations. Details close waits for frame transitions; archive verification assigns an explicit title to the seeded Session and follows that identity across reload. See the [CI fixture synchronization decision](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md). diff --git a/apps/web/tests/README.zh.md b/apps/web/tests/README.zh.md index 29ae2e8601..199ae3b561 100644 --- a/apps/web/tests/README.zh.md +++ b/apps/web/tests/README.zh.md @@ -4,6 +4,8 @@ 这些测试在进程内启动真实的 web 组合,并用真实 Chromium 通过真实 HTTP 驱动它。该 lane 的运行机制——模式、fixture(测试前置数据)、golden,以及与 `dsh web` 之间刻意保留的组合差异——记录在 [`scaffold.ts`](scaffold.ts) 和 [浏览器 e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md) 中。 +普通场景以没有已登记 Workspace 或 Session、但持久化标记记录默认 Workspace 已被删除的状态启动,使显式文件夹选择场景自行决定 cwd。`launchWebScaffold({ firstUse: true })` 保留初始化资格,供启动场景使用。 + ## 完成状态观察 依赖状态的用例使用 Workspace、接纳、附件和模型流屏障,区分可见中间状态与已完成操作。详情关闭等待框架过渡结束;归档验证为 seed Session 设置显式标题,并跨重载跟踪该身份。参见 [CI fixture 同步决策](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md)。 diff --git a/apps/web/tests/default-workspace.e2e.ts b/apps/web/tests/default-workspace.e2e.ts index 265fa715b2..d3b6db624d 100644 --- a/apps/web/tests/default-workspace.e2e.ts +++ b/apps/web/tests/default-workspace.e2e.ts @@ -1,9 +1,9 @@ -/** First-use messages prepare a durable Workspace through the shipped Web composition. */ +/** First-use startup prepares a durable Workspace through the shipped Web composition. */ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { chromium } from 'playwright' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished } from 'vitest' import { assertFinalWorkspaceSnapshot, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, selectedSessionFixture, watchConsole, webSnapshotMode, @@ -13,124 +13,125 @@ import { newEnglishPage, saveFailureShot } from './support.ts' const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url)) const EXPECTED = fileURLToPath(new URL('../../../snapshots/web/default-workspace/ui.expected.md', import.meta.url)) const FAILURE_EXPECTED = fileURLToPath(new URL('./expected/default-workspace/failure.expected.md', import.meta.url)) -const PREPARING_EXPECTED = fileURLToPath(new URL('./expected/default-workspace/preparing.expected.md', import.meta.url)) const MODE = webSnapshotMode() describe.skipIf(MODE === 'record')('web e2e: default Workspace', () => { - it('sends the first draft in a newly selected default Workspace and keeps its identity across reload', async () => { + it('prepares a blank Session on startup, reuses it after reload, and sends through the ordinary composer', async () => { const fixture = await selectedSessionFixture(FIXTURE) const scaffold = await launchWebScaffold({ + firstUse: true, replayFixture: fixture, // The shared recording remains read-only; replay compares the complete persisted Session. compareReplaySession: MODE === 'replay', paceMs: 5, }) + onTestFinished(() => scaffold.close()) + const browser = await chromium.launch() try { - const browser = await chromium.launch() + const page = await newEnglishPage(browser) + const tripwire = watchConsole(page) try { - const page = await newEnglishPage(browser) - const tripwire = watchConsole(page) - try { - await page.goto(scaffold.authenticatedUrl) - const input = page.locator('[data-composer-input][contenteditable="true"]').first() - await input.waitFor() - const prompt = fixtureUserPrompts(await readFile(fixture, 'utf8'))[0]! - await input.fill(prompt) - expect(scaffold.ctx.workspaceRegistry.list()).toEqual([]) - expect(scaffold.ctx.sessions.list()).toEqual([]) - const settled = scaffold.whenTurnSettled() - const preparing = Promise.withResolvers() - const initialize = scaffold.ctx.workspaceRegistry.initializeDefault.bind(scaffold.ctx.workspaceRegistry) - vi.spyOn(scaffold.ctx.workspaceRegistry, 'initializeDefault').mockImplementationOnce(async (resolve) => { - await preparing.promise - return initialize(resolve) - }) - try { - await input.press('Enter') - await page.getByRole('textbox', { name: 'Preparing workspace…', exact: true }).waitFor() - await compareOrRefreshGolden(PREPARING_EXPECTED, await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), MODE) - } finally { - preparing.resolve(undefined) - } - const sessionId = await settled - const workspace = scaffold.ctx.workspaceRegistry.list()[0]! - expect(workspace.title).toBe('Default workspace') - expect(workspace.path).toBe(join(scaffold.workspaceCwd, 'Documents', 'deepseek-harness', 'Default workspace')) - expect((await stat(workspace.path)).isDirectory()).toBe(true) - expect(workspace.sessionIds).toContain(sessionId) - expect(scaffold.ctx.sessions.get(sessionId)?.header.cwd).toBe(workspace.path) - await assertFinalWorkspaceSnapshot(fileURLToPath(new URL('../../../snapshots/web/default-workspace', import.meta.url)), workspace.path) - await page.getByText('DONE', { exact: true }).waitFor() - await compareOrRefreshGolden(EXPECTED, await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), MODE) - await page.reload() - await page.getByText('DONE', { exact: true }).waitFor() - expect(scaffold.ctx.workspaceRegistry.list().map(item => item.id)).toEqual([workspace.id]) - expect(tripwire.pageErrors).toEqual([]) - expect(tripwire.warnings).toEqual([]) - } catch (error) { - await saveFailureShot(page, 'web-e2e-default-workspace') - throw error - } - } finally { - await browser.close() + await page.goto(scaffold.authenticatedUrl) + const input = page.locator('[data-composer-input][contenteditable="true"]').first() + await input.waitFor() + expect(scaffold.ctx.workspaceRegistry.list()).toHaveLength(1) + const initialSession = scaffold.ctx.sessions.list()[0]! + expect(scaffold.ctx.sessions.list()).toHaveLength(1) + expect(initialSession.snapshotEvents().some(event => event.type === 'user/message')).toBe(false) + await page.reload() + await input.waitFor() + expect(scaffold.ctx.sessions.list().map(session => session.id)).toEqual([initialSession.id]) + const prompt = fixtureUserPrompts(await readFile(fixture, 'utf8'))[0]! + await input.fill(prompt) + const settled = scaffold.whenTurnSettled() + await input.press('Enter') + const sessionId = await settled + const workspace = scaffold.ctx.workspaceRegistry.list()[0]! + expect(workspace.title).toBe('Default workspace') + expect(workspace.path).toBe(join(scaffold.workspaceCwd, 'Documents', 'deepseek-harness', 'Default workspace')) + expect((await stat(workspace.path)).isDirectory()).toBe(true) + expect(workspace.sessionIds).toContain(sessionId) + expect(scaffold.ctx.sessions.get(sessionId)?.header.cwd).toBe(workspace.path) + await assertFinalWorkspaceSnapshot(fileURLToPath(new URL('../../../snapshots/web/default-workspace', import.meta.url)), workspace.path) + await page.getByText('DONE', { exact: true }).waitFor() + await compareOrRefreshGolden(EXPECTED, await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } catch (error) { + await saveFailureShot(page, 'web-e2e-default-workspace') + throw error } } finally { - await scaffold.close() + await browser.close() } }) - it('keeps the draft on a directory conflict and opens the composed folder picker for recovery', async () => { - const scaffold = await launchWebScaffold() + it('reports a startup directory conflict and opens the composed folder picker for recovery', async () => { + const scaffold = await launchWebScaffold({ firstUse: true }) + onTestFinished(() => scaffold.close()) + const parent = join(scaffold.workspaceCwd, 'Documents', 'deepseek-harness') + await mkdir(parent, { recursive: true }) + await writeFile(join(parent, '默认工作区'), 'occupied') + const chosen = join(scaffold.workspaceCwd, 'chosen') + await mkdir(chosen) + const browser = await chromium.launch() try { - const parent = join(scaffold.workspaceCwd, 'Documents', 'deepseek-harness') - await mkdir(parent, { recursive: true }) - await writeFile(join(parent, '默认工作区'), 'occupied') - const chosen = join(scaffold.workspaceCwd, 'chosen') - await mkdir(chosen) - const browser = await chromium.launch() + const page = await browser.newPage({ locale: 'zh-CN', timezoneId: 'Asia/Shanghai' }) + const tripwire = watchConsole(page) try { - const page = await browser.newPage({ locale: 'zh-CN', timezoneId: 'Asia/Shanghai' }) - const tripwire = watchConsole(page) - try { - await page.goto(scaffold.authenticatedUrl) - const input = page.locator('[data-composer-input][contenteditable="true"]').first() - await input.fill('保留这条消息') - await input.press('Enter') - const modal = page.getByRole('dialog', { name: '无法创建默认工作区' }) - await modal.waitFor() - await expect.poll(() => input.textContent()).toBe('保留这条消息') - expect(scaffold.ctx.workspaceRegistry.list()).toEqual([]) - expect(scaffold.ctx.sessions.list()).toEqual([]) - await compareOrRefreshGolden(FAILURE_EXPECTED, await modal.ariaSnapshot(), MODE) - await modal.getByRole('button', { name: '取消', exact: true }).click() - expect(await input.textContent()).toBe('保留这条消息') - await input.press('Enter') - await modal.getByRole('button', { name: '选择文件夹', exact: true }).click() - const picker = page.getByRole('dialog', { name: '选择工作区目录' }) - await picker.waitFor() - await picker.getByRole('button', { name: '编辑路径', exact: true }).click() - await picker.getByRole('textbox', { name: '编辑路径' }).fill(chosen) - await page.keyboard.press('Enter') - await picker.getByRole('button', { name: '打开', exact: true }).click() - await picker.waitFor({ state: 'hidden' }) - await expect.poll(() => scaffold.ctx.workspaceRegistry.list().length).toBe(1) - await page.getByRole('treeitem', { name: '新会话', exact: true }).waitFor() - await expect.poll(() => input.textContent()).toBe('保留这条消息') - expect(scaffold.ctx.workspaceRegistry.list()[0]?.path).toBe(chosen) - expect(scaffold.ctx.sessions.list()).toHaveLength(1) - expect(scaffold.ctx.sessions.list().every(session => - session.snapshotEvents().every(event => event.type !== 'user/message'))).toBe(true) - expect(tripwire.pageErrors).toEqual([]) - expect(tripwire.warnings).toEqual([]) - } catch (error) { - await saveFailureShot(page, 'web-e2e-default-workspace-failure') - throw error - } - } finally { - await browser.close() + await page.goto(scaffold.authenticatedUrl) + const input = page.locator('[data-composer-input][contenteditable="true"]').first() + const modal = page.getByRole('dialog', { name: '无法创建默认工作区' }) + await modal.waitFor() + expect(scaffold.ctx.workspaceRegistry.list()).toEqual([]) + expect(scaffold.ctx.sessions.list()).toEqual([]) + await compareOrRefreshGolden(FAILURE_EXPECTED, await modal.ariaSnapshot(), MODE) + await modal.getByRole('button', { name: '取消', exact: true }).click() + expect(await input.count()).toBe(0) + await page.reload() + await modal.waitFor() + await modal.getByRole('button', { name: '选择文件夹', exact: true }).click() + const picker = page.getByRole('dialog', { name: '选择工作区目录' }) + await picker.waitFor() + await picker.getByRole('button', { name: '编辑路径', exact: true }).click() + await picker.getByRole('textbox', { name: '编辑路径' }).fill(chosen) + await page.keyboard.press('Enter') + await picker.getByRole('button', { name: '打开', exact: true }).click() + await picker.waitFor({ state: 'hidden' }) + await expect.poll(() => scaffold.ctx.workspaceRegistry.list().length).toBe(1) + await input.waitFor() + expect(await input.textContent()).toBe('') + expect(scaffold.ctx.workspaceRegistry.list()[0]?.path).toBe(chosen) + expect(scaffold.ctx.sessions.list()).toHaveLength(1) + expect(scaffold.ctx.sessions.list().every(session => + session.snapshotEvents().every(event => event.type !== 'user/message'))).toBe(true) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } catch (error) { + await saveFailureShot(page, 'web-e2e-default-workspace-failure') + throw error } } finally { - await scaffold.close() + await browser.close() + } + }) + + it('keeps an explicitly deleted default absent on startup', async () => { + const scaffold = await launchWebScaffold() + onTestFinished(() => scaffold.close()) + const browser = await chromium.launch() + try { + const page = await newEnglishPage(browser) + const attempted = page.waitForResponse(response => response.url().includes('/api/workspace/initializeDefault')) + await page.goto(scaffold.authenticatedUrl) + expect((await attempted).ok()).toBe(true) + await page.getByRole('button', { name: 'Choose workspace', exact: true }).waitFor() + expect(await page.getByRole('dialog', { name: 'Unable to create default workspace' }).count()).toBe(0) + expect(await page.locator('[data-composer-input][contenteditable="true"]').count()).toBe(0) + expect(scaffold.ctx.workspaceRegistry.list()).toEqual([]) + expect(scaffold.ctx.sessions.list()).toEqual([]) + } finally { + await browser.close() } }) }) diff --git a/apps/web/tests/expected/default-workspace/preparing.expected.md b/apps/web/tests/expected/default-workspace/preparing.expected.md deleted file mode 100644 index 09e86fbb6e..0000000000 --- a/apps/web/tests/expected/default-workspace/preparing.expected.md +++ /dev/null @@ -1,7 +0,0 @@ -- banner -- text: Into the Unknown Preview -- button "Choose workspace" -- button "Standard mode" -- textbox "Preparing workspace…" [disabled] -- button "Add files or run commands" [disabled] -- button "Send message" [disabled] diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index f903205c80..16a80f5dd2 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -395,6 +395,8 @@ export interface LaunchOptions { deepSeekMessages?: boolean /** Leave the current welcome notice pending; ordinary scenarios pre-acknowledge it before browser boot. */ welcomeNoticePending?: boolean + /** Leave first-use Workspace initialization eligible; ordinary scenarios start after the default was removed. */ + firstUse?: boolean /** * Patch the shipped DeepSeek search row to a deterministic endpoint and * credential reference. Browser search scenarios keep the real provider and @@ -600,7 +602,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise ({ path: workspaceCwd, title: 'Workspace' })) + if (initial !== undefined) await ctx.workspaceRegistry.delete(initial.id) + } const boundPort = ctx.get('webServer')?.port if (boundPort === undefined) { throw new Error('web e2e scaffold: webServer service missing after settled boot') diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index f9bef3d306..452d200cf5 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.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/workspace.md -workspace.md: 43a631ad0b04dd272436e272c931c2a16b7bbe88 -workspace.zh.md: c4571083f3e445828b4b3b41a27ed8abee9ee04d +workspace.md: 323323946ac92bbc03962b83c5e77a81a960d89a +workspace.zh.md: aedd1134305aaa7e97ccd449631ff72446bf28b4 diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index 43a631ad0b..323323946a 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -306,12 +306,12 @@ Host service backing the generated `ctx.remote.workspace` namespace. @Remote('create') create(request: WorkspaceCreateRequest): Promise /** - * Initialize or reuse the default Workspace before the first user message. + * Initialize or reuse the default Workspace during first-use startup. * @param request - initial directory name and title; never rename an existing default. * @param signal - caller lifetime; cancels native directory lookup. - * @returns the durable Workspace without creating a Session or sending a message. + * @returns the durable Workspace, or undefined when first-use initialization is ineligible; creates no Session or message. */ -@Remote('initializeDefault') async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise +@Remote('initializeDefault') async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise /** * Rename one Workspace to a unique non-blank title. diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index c4571083f3..aedd113430 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -306,12 +306,12 @@ Host service backing the generated `ctx.remote.workspace` namespace. @Remote('create') create(request: WorkspaceCreateRequest): Promise /** - * Initialize or reuse the default Workspace before the first user message. + * Initialize or reuse the default Workspace during first-use startup. * @param request - initial directory name and title; never rename an existing default. * @param signal - caller lifetime; cancels native directory lookup. - * @returns the durable Workspace without creating a Session or sending a message. + * @returns the durable Workspace, or undefined when first-use initialization is ineligible; creates no Session or message. */ -@Remote('initializeDefault') async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise +@Remote('initializeDefault') async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise /** * Rename one Workspace to a unique non-blank title. diff --git a/packages/api/workspace-controller/README.i18n.yaml b/packages/api/workspace-controller/README.i18n.yaml index 6300d2f2e4..0520266936 100644 --- a/packages/api/workspace-controller/README.i18n.yaml +++ b/packages/api/workspace-controller/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/api/workspace-controller/README.md -README.md: 24bf0610689bfb857b24e7c8298b1b0494f1ca02 -README.zh.md: ca140abfdda246b62b0485e63ebfd05ab2efe6f9 +README.md: 27053331007681eb373b4537514bbe701dad660d +README.zh.md: d0726496de5dcf623fedb531ad988b3c608fc2ac diff --git a/packages/api/workspace-controller/README.md b/packages/api/workspace-controller/README.md index 24bf061068..2705333100 100644 --- a/packages/api/workspace-controller/README.md +++ b/packages/api/workspace-controller/README.md @@ -29,9 +29,9 @@ The Client entry provides `ClientWorkspaceModel` and `createWorkspaceStateStream ### First-use Workspace -`workspace.initializeDefault({ directoryName, title })` returns the durable default Workspace; the Client service exposes the same request as `workspaces.initializeDefault(request, signal?)`. The [Conversation Client](../../client/ui-conversation/README.md) resolves these names from its send-time language. The Host places the directory under its account's `/deepseek-harness`, including on remote Web hosts. The directory name must be one non-blank segment without separators, colon, NUL, surrounding whitespace, or a trailing dot; the title must be non-blank. OS filename restrictions also apply. Linux system lookup requires `xdg-user-dir` with an enabled Documents directory; hosts without it must configure `documentsDirectory` or use the folder picker. +`workspace.initializeDefault({ directoryName, title })` returns the durable default Workspace; the Client service exposes the same request as `workspaces.initializeDefault(request, signal?)`. The [Workspace Client](../../client/ui-workspace/README.md) resolves these names from its startup language. The Host places the directory under its account's `/deepseek-harness`, including on remote Web hosts. The directory name must be one non-blank segment without separators, colon, NUL, surrounding whitespace, or a trailing dot; the title must be non-blank. OS filename restrictions also apply. Linux system lookup requires `xdg-user-dir` with an enabled Documents directory; hosts without it must configure `documentsDirectory` or use the folder picker. -The [Workspace registry](../../workspace/workspace/README.md#first-use-workspace) owns eligibility, directory creation, and durable initialization. An existing default Workspace is returned without another Documents lookup; request names do not rename it. Invalid names or ineligible first use reject with `gateway/bad-request`; lookup and creation failures use standard Remote error handling. Initialization creates no Session and sends no message. +The [Workspace registry](../../workspace/workspace/README.md#first-use-workspace) owns eligibility, directory creation, and durable initialization. An existing default Workspace is returned without another Documents lookup; request names do not rename it. Ineligible first use returns `undefined`, so startup can leave directory selection to the user. Invalid names reject with `gateway/bad-request`; lookup and creation failures use standard Remote error handling. Initialization creates no Session and sends no message. | Configuration | Default | Purpose | | --- | --- | --- | diff --git a/packages/api/workspace-controller/README.zh.md b/packages/api/workspace-controller/README.zh.md index ca140abfdd..d0726496de 100644 --- a/packages/api/workspace-controller/README.zh.md +++ b/packages/api/workspace-controller/README.zh.md @@ -29,9 +29,9 @@ Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。 ### 首次使用工作区 -`workspace.initializeDefault({ directoryName, title })` 返回持久化的默认工作区;Client service 通过 `workspaces.initializeDefault(request, signal?)` 提供同一请求。[Conversation Client](../../client/ui-conversation/README.zh.md)按发送时的语言解析这些名称。Host 将目录放在其账户的 `/deepseek-harness` 下,远程 Web Host 也遵循此规则。目录名必须是非空的单个片段,不能含分隔符、冒号、NUL、首尾空白或末尾句点;标题不能为空。操作系统的文件名限制同样适用。Linux 系统查询要求存在 `xdg-user-dir` 且启用了 Documents 目录;不具备该条件的 Host 必须配置 `documentsDirectory` 或使用文件夹选择器。 +`workspace.initializeDefault({ directoryName, title })` 返回持久化的默认工作区;Client service 通过 `workspaces.initializeDefault(request, signal?)` 提供同一请求。[Workspace Client](../../client/ui-workspace/README.zh.md)按启动时的语言解析这些名称。Host 将目录放在其账户的 `/deepseek-harness` 下,远程 Web Host 也遵循此规则。目录名必须是非空的单个片段,不能含分隔符、冒号、NUL、首尾空白或末尾句点;标题不能为空。操作系统的文件名限制同样适用。Linux 系统查询要求存在 `xdg-user-dir` 且启用了 Documents 目录;不具备该条件的 Host 必须配置 `documentsDirectory` 或使用文件夹选择器。 -[Workspace 注册表](../../workspace/workspace/README.zh.md#first-use-workspace)负责资格判断、目录创建和持久化初始化。已有默认工作区直接返回,不再查询 Documents;请求中的名称不会将其重命名。名称无效或不满足首次使用条件时,以 `gateway/bad-request` 拒绝;查询和创建失败遵循标准 Remote 错误处理。初始化不创建 Session,也不发送消息。 +[Workspace 注册表](../../workspace/workspace/README.zh.md#first-use-workspace)负责资格判断、目录创建和持久化初始化。已有默认工作区直接返回,不再查询 Documents;请求中的名称不会将其重命名。不满足首次使用条件时返回 `undefined`,启动流程可将目录选择留给用户。名称无效时以 `gateway/bad-request` 拒绝;查询和创建失败遵循标准 Remote 错误处理。初始化不创建 Session,也不发送消息。 | 配置 | 默认值 | 用途 | | --- | --- | --- | diff --git a/packages/api/workspace-controller/src/client/model.ts b/packages/api/workspace-controller/src/client/model.ts index 3f6dda9f76..25a84d655e 100644 --- a/packages/api/workspace-controller/src/client/model.ts +++ b/packages/api/workspace-controller/src/client/model.ts @@ -108,9 +108,11 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink { * @param signal - caller lifetime. * @returns generated Remote result. */ - async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise> { + async initializeDefault( + request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal, + ): Promise> { const result = await this.remote.initializeDefault(request, signal) - if (result.ok) this.upsert(result.value.workspace) + if (result.ok && result.value !== undefined) this.upsert(result.value.workspace) return result } diff --git a/packages/api/workspace-controller/src/client/service.ts b/packages/api/workspace-controller/src/client/service.ts index f94baafdb2..393fed0b7f 100644 --- a/packages/api/workspace-controller/src/client/service.ts +++ b/packages/api/workspace-controller/src/client/service.ts @@ -43,9 +43,9 @@ export interface IWorkspaces { * Initialize or reuse the default Workspace. * @param request - initial directory name and title. * @param signal - caller lifetime. - * @returns the prepared Workspace; rejects when directory selection is required. + * @returns the prepared Workspace, or undefined when first-use initialization is ineligible; rejects on preparation failure. */ - initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise + initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise /** * Rename a Workspace. * @param workspaceId - target Workspace. @@ -117,10 +117,10 @@ export class WorkspaceController extends Service implements IWorkspaces { return result.value.workspace } - async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise { + async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise { const result = await this.model.initializeDefault(request, signal) if (!result.ok) throw new WorkspaceCreateError(result.error) - return result.value.workspace + return result.value?.workspace } async rename(workspaceId: WorkspaceId, title: string): Promise { diff --git a/packages/api/workspace-controller/src/index.ts b/packages/api/workspace-controller/src/index.ts index 966a5e9fb9..7a63e5b699 100644 --- a/packages/api/workspace-controller/src/index.ts +++ b/packages/api/workspace-controller/src/index.ts @@ -86,13 +86,13 @@ export class WorkspaceController extends TypertRemoteService { } /** - * Initialize or reuse the default Workspace before the first user message. + * Initialize or reuse the default Workspace during first-use startup. * @param request - initial directory name and title; never rename an existing default. * @param signal - caller lifetime; cancels native directory lookup. - * @returns the durable Workspace without creating a Session or sending a message. + * @returns the durable Workspace, or undefined when first-use initialization is ineligible; creates no Session or message. */ @Remote('initializeDefault') - async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise { + async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise { const { directoryName, title } = request if (directoryName.trim() === '' || directoryName !== directoryName.trim() || directoryName.endsWith('.') || /[/\\:\0]/.test(directoryName) || title.trim() === '') { @@ -105,10 +105,7 @@ export class WorkspaceController extends TypertRemoteService { ) return { path, title } }) - if (workspace === undefined) { - throw new RemoteError('gateway/bad-request', 'choose a directory to create a Workspace', {}) - } - return { workspace: workspaceView(workspace) } + return workspace === undefined ? undefined : { workspace: workspaceView(workspace) } } /** diff --git a/packages/api/workspace-controller/tests/model.client.spec.ts b/packages/api/workspace-controller/tests/model.client.spec.ts index 4558d4f64d..e4f11a7678 100644 --- a/packages/api/workspace-controller/tests/model.client.spec.ts +++ b/packages/api/workspace-controller/tests/model.client.spec.ts @@ -177,6 +177,10 @@ describe('ClientWorkspaceModel', () => { expect(remote.initializeDefault).toHaveBeenCalledWith({ directoryName: '默认工作区', title: '默认工作区' }, signal) expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['default']) const before = model.getSnapshot() + remote.initializeDefault.mockResolvedValueOnce(remoteOk(undefined)) + await expect(model.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' })) + .resolves.toEqual({ ok: true, value: undefined }) + expect(model.getSnapshot()).toBe(before) remote.initializeDefault.mockResolvedValueOnce(workspaceError( new RemoteError('gateway/bad-request', 'choose a folder', {}), )) diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index 9829004125..220f83e069 100644 --- a/packages/api/workspace-controller/tests/transport.client.spec.ts +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -310,6 +310,18 @@ describe('Workspace state stream', () => { }) describe('WorkspaceController', () => { + it('returns no Workspace when startup is ineligible without changing the list', async ({ mock, start }) => { + const { remote, client } = await gatewayClient(mock, start) + const model = new ClientWorkspaceModel(remote.workspace) + model.replaceBaseline({ items: [], archivedSessionIds: [], pinnedSessionIds: [] }) + const controller = new WorkspaceController(client.ctx, model) + const before = model.getSnapshot() + mock.remote.workspace.initializeDefault.mockResolvedValueOnce({ ok: true, value: undefined }) + await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' })) + .resolves.toBeUndefined() + expect(model.getSnapshot()).toBe(before) + }) + it('publishes the model source and exposes successful Workspace commands', async ({ mock, start }) => { const { remote, client } = await gatewayClient(mock, start) const model = new ClientWorkspaceModel(remote.workspace) diff --git a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts index 39cd085879..0656aaf8bf 100644 --- a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts +++ b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts @@ -447,24 +447,24 @@ describe('first-use Remote', () => { it('uses the requested display title independently of the directory name', async () => { const { controller, root } = await harness() const result = await controller.initializeDefault({ directoryName: 'default-workspace', title: 'Default workspace' }, new AbortController().signal) - expect(result.workspace).toMatchObject({ path: join(root, 'deepseek-harness', 'default-workspace'), title: 'Default workspace' }) + expect(result?.workspace).toMatchObject({ path: join(root, 'deepseek-harness', 'default-workspace'), title: 'Default workspace' }) }) it('returns a durable Workspace without allocating a Session', async () => { const { controller, ctx, root } = await harness() const signal = new AbortController().signal const result = await controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, signal) - expect(result.workspace.path).toBe(join(root, 'deepseek-harness', 'Default workspace')) - expect(existsSync(result.workspace.path)).toBe(true) + expect(result!.workspace.path).toBe(join(root, 'deepseek-harness', 'Default workspace')) + expect(existsSync(result!.workspace.path)).toBe(true) expect(ctx.sessions.list()).toEqual([]) expect(await controller.initializeDefault({ directoryName: '默认工作区', title: '默认工作区' }, signal)).toEqual(result) }) - it('rejects ineligible first use and propagates preparation failures', async () => { + it('skips ineligible first use and propagates preparation failures', async () => { const { controller, ctx, root } = await harness() await ctx.workspaceRegistry.create(root) await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, new AbortController().signal)) - .rejects.toMatchObject({ code: 'gateway/bad-request' }) + .resolves.toBeUndefined() vi.spyOn(ctx.workspaceRegistry, 'initializeDefault').mockRejectedValueOnce(new Error('permission denied')) await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, new AbortController().signal)) .rejects.toThrow('permission denied') diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 9a9afcdd63..3b67b4ca8a 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/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-agent-preset/README.md -README.md: 5545dbbddf18d6f181d5e8c17e194043b3e0bf9d -README.zh.md: 4ef159280dd5e72c9d12aa75905855a2b9bf8d27 +README.md: 5f2c9acaa5840e71d6ca237c5fc5eccb52724cda +README.zh.md: 5c61823c25f145c0e516f3f2a16c2de05c250ef1 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 5545dbbddf..5f2c9acaa5 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -49,8 +49,6 @@ The settings section writes the Host's existing `agent-presets` namespace throug The chip follows the preset projection of its bound Session while its plugin and Provider generation remain active. Settings synchronization creates the same bound controller when needed, even before the chip mounts. A late projection or another client's preset update refreshes the display without submitting a selection; list notifications do not repeat an in-flight selection. Settings changes wait for that selection and recheck the captured blank Session before applying. A choice staged during a pending request remains available afterward. -After preparing a default Workspace, the first-use draft waits for the staged preset through `conversation/prepare-first-send`. Concurrent `apply()` calls wait for the active selection without repeating it. A refusal keeps the unsent Session draft. - ----- diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 4ef159280d..5c61823c25 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -49,8 +49,6 @@ kind: "package-reference" 只要插件及 Provider generation 仍有效,选择芯片就会跟随其绑定 Session 的预设投影。即使选择芯片尚未挂载,设置同步也会按需创建同一个绑定控制器。延后到达的投影或其他客户端的预设更新会刷新显示,而不会提交选择;列表通知不会重复仍在进行中的选择操作。设置修改会等待该操作结束,并在应用之前重新检查已捕获的空白 Session。请求期间暂存的新选择会保留。 -默认工作区准备完成后,首次使用的草稿通过 `conversation/prepare-first-send` 等待暂存的模式选择。并发的 `apply()` 调用等待已有选择操作,不会重复提交。选择被拒绝时保留未发送的 Session 草稿。 - ----- diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 113e440900..0838fd89aa 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -143,14 +143,6 @@ export function apply(ctx: ClientContext): void { // render and simply hides the button while no flow exists. let creatorDraft: (() => void) | undefined ctx.inject(['slots', 'conversation', 'sessions', 'uiWorkspace'], (scope: ClientContext) => { - scope.on('conversation/prepare-first-send', async (sessionId) => { - const binding = scope.sessions.binding(sessionId) - if (binding === undefined) return - const seat = seatFor(binding) - if (staged.id === undefined && !seat.store.getSnapshot().busy) return - const error = await seat.apply() - if (error !== undefined) throw new Error(error) - }) const seatInjected = (sessionId: SessionId | undefined): AgentPresetSeatInjected => { const binding = sessionId === undefined ? undefined : ctx.sessions.binding(sessionId) const seat = binding === undefined ? unboundSeat : seatFor(binding) diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts index 4e7488bc3b..45bd9373e4 100644 --- a/packages/client/ui-agent-preset/src/client/seat-store.ts +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -193,18 +193,11 @@ export class AgentPresetSeatController { * * Called both by `select()` and by whoever observes the current session * changing, because the session may appear either before or after the pick. - * Concurrent callers wait for the active selection without repeating it. - * @returns the Host refusal, or undefined when successful or no switch starts. + * List updates do not repeat a selection while its response is pending. + * @returns this attempt's Host refusal, or undefined when successful or no switch starts. */ async apply(): Promise { - let pending = this.pendingSelection - while (pending !== undefined) { - await pending - const error = this.store.getSnapshot().error - if (error !== null) return error - pending = this.pendingSelection - if (pending === undefined && this.staged.id === undefined) return - } + if (this.store.getSnapshot().busy) return const staged = this.staged.id const session = this.currentSession() if (staged === undefined) { diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index 378b40006f..1e28cb44f4 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -6,7 +6,7 @@ */ import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' @@ -76,7 +76,6 @@ async function bench(options: { failSettingsUpdate?: boolean selectGate?: Promise settingsRosterGate?: Promise - refuseSelect?: boolean } = {}) { const ctx = new Context() // The host's answer, mutable so a spec can move the default the way the @@ -154,9 +153,7 @@ async function bench(options: { deletePreset: () => Promise.resolve({ ok: true as const, value: undefined }), select: (_agentId: SessionId, agentPreset: string) => { calls.push(`select:${agentPreset}`) - return Promise.resolve(options.selectGate).then(() => options.refuseSelect === true - ? { ok: false as const, error: new RemoteError('gateway/internal', 'preset refused', {}) } - : { ok: true as const, value: agentPreset }) + return Promise.resolve(options.selectGate).then(() => ({ ok: true as const, value: agentPreset })) }, } ctx.provide('remote.agentPresets', agentPresets as never) @@ -718,10 +715,8 @@ describe('ui-agent-preset apply', () => { conversation() }) - it.each([false, true])('awaits the staged choice before first send and propagates refusal (%s)', async (refuseSelect) => { - const selection = Promise.withResolvers() - const { ctx, slots, calls } = await bench({ refuseSelect, selectGate: selection.promise }) - onTestFinished(async () => { selection.resolve(undefined); await ctx.fiber.dispose() }) + it('applies the staged choice to the blank session the flow lands on', async () => { + const { ctx, slots, calls } = await bench() declareRoot(slots) declareConversation(slots) ctx.provide('conversation', {} as never) @@ -741,8 +736,6 @@ describe('ui-agent-preset apply', () => { .inject as unknown as (sessionId?: SessionId) => AgentPresetSeatInjected const chip = injectSeat() - await ctx.serial('conversation/prepare-first-send', SessionId('absent')) - await chip.load() // Picked on the hero screen, where there is no session yet. await chip.select('minimal') @@ -754,16 +747,10 @@ describe('ui-agent-preset apply', () => { } sessions.notify() const bound = injectSeat(SessionId('s1')) - const preparing = ctx.serial('conversation/prepare-first-send', SessionId('s1')) - const settled = refuseSelect ? expect(preparing).rejects.toThrow('preset refused') : expect(preparing).resolves.toBeUndefined() - selection.resolve(undefined) - await settled - await ctx.serial('conversation/prepare-first-send', SessionId('s1')) await bound.load() // Connecting a workspace produced the session; the stage reaches it there. await vi.waitFor(() => { expect(calls).toContain('select:minimal') }) - expect(calls.filter(call => call === 'select:minimal')).toHaveLength(1) }) it('applies the stage to a session that records no preset of its own', async () => { @@ -959,28 +946,6 @@ describe('ui-agent-preset apply', () => { }) describe('AgentPresetSeatController reconciliation', () => { - it.each([false, true])('shares the result of an active preset selection (refused: %s)', async (refused) => { - const refusal = { ok: false as const, error: new RemoteError('gateway/internal', 'selection refused', {}) } - const reply = Promise.withResolvers<{ ok: true; value: string } | typeof refusal>() - const select = vi.fn(() => reply.promise) - const controller = new AgentPresetSeatController({ remote: { agentPresets: { select } } } as never, - () => ({ id: SessionId('blank'), blank: true, projectionValues: { agentPreset: 'standard' } })) - const selecting = controller.select('minimal') - let prepared = false - const preparation = controller.apply().finally(() => { prepared = true }) - try { - expect(prepared).toBe(false) - expect(select).toHaveBeenCalledOnce() - reply.resolve(refused ? refusal : { ok: true, value: 'minimal' }) - await expect(preparation).resolves.toBe(refused ? 'selection refused' : undefined) - await selecting - expect(select).toHaveBeenCalledOnce() - } finally { - reply.resolve({ ok: true, value: 'minimal' }) - await Promise.allSettled([selecting, preparation]) - } - }) - it.each([ { refuseFirst: false, refuseLatest: false }, { refuseFirst: false, refuseLatest: true }, @@ -1001,17 +966,16 @@ describe('AgentPresetSeatController reconciliation', () => { const first = controller.select('minimal') let settingsSettled = false const settings = controller.syncBlankSession(session.id, 'cordis').finally(() => { settingsSettled = true }) - const repeatedFirst = controller.apply() + await controller.apply() expect(requests.map(request => request.preset)).toEqual(['minimal']) requests[0]!.outcome.resolve(refuseFirst ? refusal : { ok: true, value: 'minimal' }) await first expect(requests.map(request => request.preset)).toEqual(['minimal', 'cordis']) expect(settingsSettled).toBe(false) - const repeatedLatest = controller.apply() + await controller.apply() expect(requests).toHaveLength(2) requests[1]!.outcome.resolve(refuseLatest ? refusal : { ok: true, value: 'cordis' }) await expect(settings).resolves.toBe(refuseLatest ? 'selection refused' : undefined) - await Promise.all([repeatedFirst, repeatedLatest]) expect(controller.store.getSnapshot()).toMatchObject({ current: refuseLatest ? 'standard' : 'cordis', busy: false, error: refuseLatest ? 'selection refused' : null, @@ -1028,10 +992,10 @@ describe('AgentPresetSeatController reconciliation', () => { () => ({ id: SessionId('blank'), blank: true, projectionValues: { agentPreset: 'standard' } })) const first = controller.select('minimal') controller.stage('cordis', true) - const repeated = controller.apply() + await controller.apply() expect(select).toHaveBeenCalledOnce() firstReply.resolve(refused ? refusal : { ok: true, value: 'minimal' }) - await Promise.all([first, repeated]) + await first expect(controller.store.getSnapshot().current).toBe('cordis') await controller.apply() expect(select.mock.calls.map(call => call[1])).toEqual(['minimal', 'cordis']) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 2bcda4be9c..b871029e8d 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: f921aa4a9aa7e9cea0a8a2c2c875b3f1d7c27bd8 -README.zh.md: b432995b1e42292a65558f37f153920cc7ecbbf2 +README.md: 16e821ed4caabae491d552a823bd69a2e976e2bd +README.zh.md: 7316b00c69ca74284b81678082d93e870dee6bbe diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index f921aa4a9a..16e821ed4c 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -50,10 +50,6 @@ Claimed commands retain their identity and highlight when only their arguments a Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and commit navigation. Draft text and attachments move in its synchronous preparation callback only while that request is current; later navigation or owner disposal leaves the original draft intact. -The Client chooses the default directory name and initial title when sending: Chinese uses `默认工作区`, English uses `Default workspace`, and other languages use directory `default-workspace` with title `Default workspace`. The request retains those names during initialization. A retry after failed initialization uses the current language; a successfully initialized Workspace keeps its directory and title across language changes. - -On first use, the same input accepts text before a Workspace is selected. The first send prepares the [default Workspace](../../workspace/workspace/README.md#first-use-workspace), selects its blank Session, transfers the draft, and uses ordinary message submission. Preparation locks further input. Failure preserves the draft and offers Cancel or Choose folder through the existing picker. A manually chosen folder receives the unsent draft; the user sends again. Once created, the Workspace survives a prompt failure. `conversation/prepare-first-send` awaits staged Session settings before automatically submitting the transferred first-use draft; a rejected listener keeps that draft and reports its error. If navigation releases the destination Session while preparation is pending, automatic submission stops and its persisted draft remains available on reopening. - The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the resident `conversation.header` outside the optional-Session `conversation.content` Component Factory. The header hosts root-scoped navigation without a selected Session; titles, actions, and View tabs remain in its strict Session child. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes strict-Session `views` plus root-scoped `widthControls` local positions. Its default adapter renders the existing `conversation.session` entry, while the main occurrence selects the width handles; an embedded occurrence can replace `views` and omit those handles without rendering the main Header. The shared body and Composer register the queue and Todo docks. The Todo dock uses the shared panel elevation above the composer; its rows use the shared idle, ongoing, and done markers for pending, in-progress, and completed items. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent; the resident container reserves a 40px drag band on macOS desktop and no empty vertical space on Web, Windows, or Linux. Sidebar entries retain their own data and execution prerequisites. A started Session header uses one row when fewer than two Views are available; the tab row reserves space only while rendered. @@ -64,7 +60,7 @@ The shell reads the persisted View preference before rendering when a Session fi Active transcripts in the main occurrence expose content-width drag handles in their uncovered side gutters; embedded occurrences omit them. A View that paints into a gutter raises only its concrete painted element above the handle; transparent full-width wrappers stay below so they do not claim empty gutter. This requires the path between that element and the Conversation body to remain outside an intermediate stacking context; the shipped Chromium behavior is pinned by the browser scenario. Chat applies the rule to table elements, while its column-bounded tool cards need no raise. Wheel motion over a handle still scrolls the transcript, while Ctrl+wheel remains a browser zoom gesture. The sticky composer intentionally owns its full footer band, which is not a resize target; an already-captured drag lifts its indicator until release ([handle styles](src/client/skeleton/ConversationRoot.module.css)). -The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. Before a Session exists, the composer holds a local text draft; sending waits for complete Workspace and Session baselines and then prepares the default Workspace. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start. +The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start. Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index b432995b1e..7316b00c69 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -50,10 +50,6 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, 工作区选择使用 `uiWorkspace.openWorkspace` 准备目标并提交导航。草稿文字和附件仅在该请求仍为当前请求时,通过它的同步准备回调搬移;后续导航或所有者释放会保留原草稿。 -Client 在发送时决定默认目录名和初始标题:中文使用 `默认工作区`,英文使用 `Default workspace`,其他语言使用目录 `default-workspace` 和标题 `Default workspace`。初始化期间,请求保留这些名称。初始化失败后的重试使用当前语言;初始化成功的 Workspace 在切换语言后仍保留原目录和标题。 - -首次使用时,同一个输入框允许在选择 Workspace 前输入文字。首条消息会准备[默认工作区](../../workspace/workspace/README.zh.md#first-use-workspace)、选中其 blank Session、转移草稿,再使用普通消息提交流程。准备期间锁定后续输入。失败时保留草稿,并提供“取消”或通过已有选择器“选择文件夹”。手动选择的文件夹接收未发送草稿,用户再次发送。Workspace 创建成功后,不会因提示词发送失败而消失。`conversation/prepare-first-send` 在自动提交已转移的首次使用草稿前等待暂存的 Session 设置完成;监听器拒绝时保留该草稿并显示错误。如果导航在准备期间释放了目标 Session,自动提交会停止,已持久化的草稿会在重新打开时保留。 - 本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` 外壳将常驻的 `conversation.header` 放在可选 Session 的 `conversation.content` Component Factory 外。未选中 Session 时,头部仍承载根作用域导航;标题、操作和 View 标签保留在严格 Session 子组件中。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开 strict-Session `views` 与 root-scoped `widthControls` 两个局部位置。默认 adapter 渲染现有 `conversation.session` entry,主 occurrence 选择宽度拖拽条;嵌入式 occurrence 可以替换 `views`、省略拖拽条,且不渲染主 Header。共享正文与 Composer 注册 queue dock 和 Todo dock。Todo dock 在 composer 上方使用共享面板 elevation;其中的行分别以共享 idle、ongoing 与 done 标记表示待处理、进行中与已完成。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载;常驻容器在 macOS 桌面保留 40px 拖拽区域,在 Web、Windows 或 Linux 上不预留空白高度。侧栏各入口仍遵循自身的数据与执行环境要求。 已开始的 Session 在可用 View 少于两个时使用单行标题栏,仅在渲染标签行时保留其高度。 @@ -64,7 +60,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 主 occurrence 的活跃 transcript 只在未被内容覆盖的两侧沟槽中提供正文宽度拖拽条;嵌入式 occurrence 省略这些拖拽条。View 如果绘制进沟槽,只将具体的可见元素提到拖拽条上方;透明的全宽包装层保持在下方,不会占用空白沟槽。该规则要求此元素与 Conversation body 之间不能引入中间堆叠上下文;浏览器场景固定了交付 Chromium 的行为。Chat 将该规则用于表格元素,其限定在阅读列内的工具卡片无需提高层级。指针位于拖拽条上时,滚轮仍会滚动 transcript,Ctrl+滚轮则保留为浏览器缩放手势。粘滞 composer 刻意拥有完整的底部区带,该区域不是宽度调整目标;已捕获的拖拽会将指示线提高到松开为止([拖动手柄样式](src/client/skeleton/ConversationRoot.module.css))。 -常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。Session 尚未存在时,composer 保存本地文字草稿;发送会等待 Workspace 与 Session 基线完整加载,然后准备默认工作区。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。 +常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。 默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index a02e0737f0..07d754d873 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -2,7 +2,6 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { ISessions, SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' -import type { WorkspaceInitializeDefaultRequest } from '@deepseek-ai/dsh-api-workspace-controller/client' import { IconPaperclipOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import { createSnapshotStore, type BoundActions } from '@deepseek-ai/dsh-client-store' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' @@ -18,13 +17,13 @@ import type { ComposerBarInjected, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected, DraftFileUploads, } from './contract/slots.ts' +import type { InputNotice } from './contract/input.ts' import { createConversationStore, readConversationViewPreference } from './stores.ts' import { ConversationController, UnsupportedImageMediaTypeError } from './service.ts' import type { IConversation } from './service.ts' import { ComposerBlockRegistry } from './input/blocks.ts' import type { ComposerBlock } from './contract/composer-blocks.ts' import { InputHub } from './input/hub.ts' -import { FirstDraft } from './input/first-draft.ts' import { ComposerSubmissionPolicy } from './input/submission-policy.ts' import { queueDockEntry } from './queue/QueueDock.tsx' import { EnterBehaviorRow } from './settings/EnterBehaviorRow.tsx' @@ -65,10 +64,19 @@ export const Config: z = z.object({ // Stable no-session sources keep the renderer's observable-hook cache and // hook order unchanged across current-Session transitions. +const ABSENT_NOTICES = { + getSnapshot: (): InputNotice | null => null, + subscribe: () => () => {}, +} const ABSENT_BLOCK = { getSnapshot: (): ComposerBlock | undefined => undefined, subscribe: () => () => {}, } +const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map() +const ABSENT_LEXICON = { + getSnapshot: () => EMPTY_LEXICON, + subscribe: () => () => {}, +} const ABSENT_MENU_LAUNCHER = { getSnapshot: (): string | null => null, subscribe: () => () => {}, @@ -79,19 +87,12 @@ const ABSENT_FILE_UPLOADS = { subscribe: () => () => {}, } -// ui-workspace consumes Conversation slots, so this reverse runtime dependency -// uses a local method projection to keep the TypeScript project graph acyclic. interface WorkspaceNavigation { openSession(sessionId: SessionId): void openWorkspace( workspaceId: Parameters[0], beforeOpen: (sessionId: SessionId) => void, ): Promise - openDefaultWorkspace( - request: WorkspaceInitializeDefaultRequest, - beforeOpen: (sessionId: SessionId) => void, - signal: AbortSignal, - ): Promise } /** Action registration used by the composer without importing its command-UI consumer. */ @@ -205,19 +206,6 @@ export function apply(ctx: Context, config: Config = Config({})): void { }, 'ui-conversation: View selection') const inputHub = new InputHub(ctx, t) - const firstDraft = new FirstDraft(ctx, { - open: (beforeOpen, signal) => { - const language = ctx.locale.getSnapshot().active.toLowerCase().split('-')[0] - // Persisted Workspace names follow the send-time language, including the English fallback. - const title = (language === 'zh' ? zh : en)['defaultWorkspace.title'] - return workspaceNavigation.openDefaultWorkspace({ - directoryName: language === 'zh' || language === 'en' ? title : 'default-workspace', - title, - }, beforeOpen, signal) - }, - shell: id => inputHub.shell(id), - isCurrent: (id, shell) => sessions.binding(id) !== undefined && inputHub.shell(id) === shell, - }) const composerBlocks = new ComposerBlockRegistry() ctx.inject(['commandUi'], (scope) => { @@ -278,11 +266,8 @@ export function apply(ctx: Context, config: Config = Config({})): void { inject: (sessionId: SessionId | undefined): ConversationInjected => ({ hooks: { composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId), - firstDraft: firstDraft.state, }, - dismissDefaultFailure: () => { firstDraft.dismiss() }, selectWorkspace: workspaceId => workspaceNavigation.openWorkspace(workspaceId, (nextId) => { - if (sessionId === undefined) firstDraft.transfer(nextId) if (sessionId !== undefined && nextId !== sessionId) { const from = inputHub.shell(sessionId) const draft = from.snapshot.draft @@ -388,7 +373,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { inject: (sessionId: SessionId | undefined): ComposerBarInjected => { if (sessionId === undefined) { return { - keyboard: firstDraft.shell, + keyboard: undefined, addFiles: undefined, removeAttachment: undefined, resolveDraftAttachments: undefined, @@ -396,11 +381,10 @@ export function apply(ctx: Context, config: Config = Config({})): void { toggleCommandMenu: undefined, stop: undefined, hooks: { - composerInput: firstDraft.shell.state, busyEnter: submissionPolicy.busyEnter, fileUploads: ABSENT_FILE_UPLOADS, - notices: firstDraft.shell.notices, - lexicon: firstDraft.shell.lexicon, + notices: ABSENT_NOTICES, + lexicon: ABSENT_LEXICON, menuLauncher: ABSENT_MENU_LAUNCHER, }, } @@ -449,7 +433,6 @@ export function apply(ctx: Context, config: Config = Config({})): void { }) }, hooks: { - composerInput: shell.state, busyEnter: submissionPolicy.busyEnter, fileUploads: conversation.fileUploads, notices: shell.notices, diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index be338eb3df..b4cd6b9bdf 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -34,12 +34,6 @@ export interface SubmitOutcome { readonly text?: string } -/** State of first-use Workspace preparation. */ -export interface FirstDraftState { - readonly busy: boolean - readonly failed: boolean -} - /** Command-mode credential supplied by one input-trigger source. */ export interface CommandClaim { /** Catalog command name without the leading slash (the key of per-command composer copy such as `hint.*`). */ diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index a0d9a6d592..023babf26e 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -16,7 +16,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import type { ComposerBlock } from './composer-blocks.ts' -import type { DraftAttachmentId, FirstDraftState, InputActions, InputNotice, InputState } from './input.ts' +import type { DraftAttachmentId, InputActions, InputNotice, InputState } from './input.ts' import type { ComposerKeyboard, EditSelection } from './draft-editor.ts' import type { createConversationStore } from '../stores.ts' import type { BusyEnterBehavior } from './composer-submission.ts' @@ -319,13 +319,8 @@ export type ConvViewProps = PropsRuntime<'conversation.view'> export interface ConversationInjected { /** Connect and open a blank Session in the selected Workspace. */ selectWorkspace: (workspaceId: WorkspaceId) => Promise - /** Dismiss default Workspace creation failure without discarding input. */ - dismissDefaultFailure: () => void /** Session-addressed composer block source, or the stable absent source. */ - hooks: { - composerBlock: ObservableSnapshot - firstDraft: ObservableSnapshot - } + hooks: { composerBlock: ObservableSnapshot } } /** Business callbacks injected into the strict Session body. */ @@ -370,7 +365,7 @@ export interface ComposerBarOwnerProps { /** Package-private operations injected into the resident composer bar. */ export interface ComposerBarInjected { - keyboard: ComposerKeyboard + keyboard: ComposerKeyboard | undefined addFiles: ((files: readonly File[]) => string | null) | undefined removeAttachment: ((id: DraftAttachmentId) => void) | undefined resolveDraftAttachments: ((ids: readonly DraftAttachmentId[]) => readonly ComposerAttachment[]) | undefined @@ -379,8 +374,6 @@ export interface ComposerBarInjected { toggleCommandMenu: ((selection: EditSelection) => void) | undefined stop: (() => void) | undefined hooks: { - /** Resident editor state, including input before a Session exists. */ - composerInput: ObservableSnapshot /** * Live busy-state submission preference: the delivery mode plain Enter * and the primary Send button use while the addressed agent is busy. diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 468fc9c198..73ddc6d7d3 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -72,14 +72,6 @@ export type { ArbitrateKey, ArbitrateOutcome, ReferenceInsert, TokenSpan } from export type { ComposerBlock, ComposerBlocks } from './contract/composer-blocks.ts' declare module '@deepseek-ai/cordis' { - interface Events { - /** - * Finish staged settings before automatically submitting the first-use draft to its new Session. - * @mode serial - * @param sessionId - destination Session. - */ - 'conversation/prepare-first-send'(sessionId: import('@deepseek-ai/dsh-session/types').SessionId): void | Promise - } interface Context { /** Scope-addressed Conversation actions and per-Session input registry. */ conversation: import('./service.ts').IConversation diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 77bff28708..4f4d0aa300 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -5,8 +5,8 @@ * scoped-event application verbs, the submit transaction plumbing * (adjudicate via the session's InputTriggerController; claim.submit; default * sink), the notice channel, and the draft persistence mirror. - * Package-private; InputHub binds Session events, while FirstDraft owns the - * local editor used before a Session exists. + * Package-private; the hub alone constructs it and wires the scoped event + * listeners onto it. */ import type { Context } from '@deepseek-ai/cordis' import type { InboxState } from '@deepseek-ai/dsh-agent/types' diff --git a/packages/client/ui-conversation/src/client/input/first-draft.ts b/packages/client/ui-conversation/src/client/input/first-draft.ts deleted file mode 100644 index 8dc7fb32a6..0000000000 --- a/packages/client/ui-conversation/src/client/input/first-draft.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** Local text draft retained until the first Workspace and Session can accept it. */ -import type { Context } from '@deepseek-ai/cordis' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { FirstDraftState, SubmitOutcome } from '../contract/input.ts' -import type { InputSubmitMode } from '../contract/composer-submission.ts' -import { SessionInputShell } from './facade.ts' - -/** First-use navigation and the resident destination input. */ -interface FirstDraftDeps { - open(beforeOpen: (id: SessionId) => void, signal: AbortSignal): Promise - shell(id: SessionId): SessionInputShell - /** Whether the borrowed input still belongs to the retained Session generation. */ - isCurrent(id: SessionId, shell: SessionInputShell): boolean -} - -/** Owns a root-lifetime text editor without allocating a Host Session. */ -export class FirstDraft { - /** Preparation progress and the recoverable directory failure. */ - readonly state = createSnapshotStore({ busy: false, failed: false }) - /** Local editor owns failed-submit restoration before transfer to a Host Session. */ - readonly shell: SessionInputShell - private pending: { text: string; transferred: boolean } | undefined - - /** @param ctx - Client plugin lifetime. @param deps - Workspace navigation and Session input access. */ - constructor(private readonly ctx: Context, private readonly deps: FirstDraftDeps) { - this.shell = new SessionInputShell({ - actx: ctx, - defaultSink: (text, _attachments, mode, signal) => this.send(text, mode, signal), - commandAttachments: { - serialize: () => Promise.resolve([]), - release: () => {}, - unsupportedNotice: () => '', - }, - }) - ctx.effect(() => () => { this.shell.dispose() }, 'conversation.input: first-use draft') - } - - /** Dismiss the preparation failure while retaining the draft. */ - dismiss(): void { - this.state.set({ ...this.state.getSnapshot(), failed: false }) - } - - /** - * Move the unsent text to a selected Session. - * @param id - retained destination Session receiving the unsent text. - */ - transfer(id: SessionId): void { - const text = this.pending?.text ?? this.shell.snapshot.draft - if (text !== '') this.deps.shell(id).setDraft(text) - if (this.pending !== undefined) this.pending.transferred = true - this.shell.setDraft('') - this.dismiss() - } - - private async send(text: string, mode: InputSubmitMode, signal: AbortSignal): Promise { - if (this.pending !== undefined) return { kind: 'error' } - const pending = { text, transferred: false } - this.pending = pending - this.state.set({ busy: true, failed: false }) - let target: SessionInputShell | undefined - try { - const id = await this.deps.open((nextId) => { this.transfer(nextId) }, signal) - if (id === undefined) return { kind: pending.transferred ? 'success' : 'error' } - target = this.deps.shell(id) - await this.ctx.serial('conversation/prepare-first-send', id) - signal.throwIfAborted() - if (!this.deps.isCurrent(id, target)) return { kind: 'success' } - target.submit(mode) - return { kind: 'success' } - } catch (error) { - if (!signal.aborted) { - if (target !== undefined) target.notify('error', error instanceof Error ? error.message : String(error)) - else if (!pending.transferred) this.state.set({ busy: true, failed: true }) - } - return { kind: pending.transferred ? 'success' : 'error' } - } finally { - this.pending = undefined - this.state.set({ ...this.state.getSnapshot(), busy: false }) - } - } -} diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index ecf2141f1d..464ff55e17 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -9,10 +9,6 @@ const PLAN_NEXT_ACTION_EN = 'describe your task to generate plan' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'defaultWorkspace.failed': '无法创建默认工作区', - 'defaultWorkspace.title': '默认工作区', - 'defaultWorkspace.retry': '请选择一个文件夹后重试', - 'defaultWorkspace.choose': '选择文件夹', 'hint.plan': PLAN_NEXT_ACTION_ZH, 'hint.goal': '输入目标,智能体将持续执行', 'hint.goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除', @@ -22,7 +18,6 @@ export const zh = { 'placeholder.parentOffline': '父会话已离线,无法继续发送;仍可停止当前运行', 'placeholder.hero': '描述你想要构建的内容, / 调用指令, @ 文件或对话', 'placeholder.workspace': '选择一个工作区开始', - 'placeholder.preparing': '正在准备工作区…', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息', 'input.commands': '添加文件或调用指令', 'input.file': '文件', @@ -365,10 +360,6 @@ export type ConversationKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { - 'defaultWorkspace.failed': 'Unable to create default workspace', - 'defaultWorkspace.title': 'Default workspace', - 'defaultWorkspace.retry': 'Please choose a folder and try again', - 'defaultWorkspace.choose': 'Choose folder', 'hint.plan': PLAN_NEXT_ACTION_EN, 'hint.goal': 'describe the objective for a long-running task', 'hint.goal.active': 'goal active — edit / pause / resume / clear', @@ -378,7 +369,6 @@ export const en = { 'placeholder.parentOffline': 'Parent session offline; sending is unavailable but you can still stop the run', 'placeholder.hero': 'Describe what you want to build, / commands, @ files or sessions', 'placeholder.workspace': 'Choose a workspace to start', - 'placeholder.preparing': 'Preparing workspace…', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages', 'input.commands': 'Add files or run commands', 'input.file': 'File', diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx index 82ea0e533d..adace1c113 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' -import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import type { ConversationContentProps, ConversationViewsProps, InputZone } from '../contract/slots.ts' import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx' @@ -23,7 +22,7 @@ export function ConversationContent(props: ConversationContentProps) { const { sessionId, phase, hero, useSession, useSessions, useSessionStatus, useWorkspaces, useInput, useComposerBlock, renderSlot, renderSlotChain, - selectWorkspace, dismissDefaultFailure, useFirstDraft, t, useFactorySlot, + selectWorkspace, t, useFactorySlot, } = props const session = useSession(snapshot => snapshot) const Views = useFactorySlot('views', ConversationSessionView) @@ -34,8 +33,6 @@ export function ConversationContent(props: ConversationContentProps) { const inputState = useInput(s => s) const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd) const workspaces = useWorkspaces(s => s) - const sessionsReady = useSessions(s => s.phase === 'ready') - const firstDraft = useFirstDraft(s => s) // A plugin this package cannot import (ui-model-selection) says this session cannot // send; its reason is already localized by whoever raised it. const composerBlock = useComposerBlock(block => block) @@ -130,22 +127,24 @@ export function ConversationContent(props: ConversationContentProps) {
) - // First-use sending waits for both baselines. A blank Session whose - // Workspace disappeared still requires explicit directory selection. - const needsWorkspace = sessionId !== undefined && hero && chipTitle === undefined - const inert = firstDraft.busy || (sessionId === undefined - ? workspaces.phase !== 'ready' || !sessionsReady - : needsWorkspace) - // Owner availability takes precedence over a Session-specific model block. + // The placeholder chip ("Choose workspace") and the Workspace-trigger input travel + // together: no workspace picked yet (cold start, no session at all), or a + // blank session whose workspace vanished (deleted from the sidebar). The + // bar is ONE session-maybe slot rendered unconditionally — inert is a prop, + // not a different tree, so the textarea DOM survives the transition. + const inert = sessionId === undefined || (hero && chipTitle === undefined) + // A raised block is the same inert posture with the blocker's own reason: + // one disabled textarea, never a second tree. The no-workspace state wins + // when both hold — picking a workspace is the earlier prerequisite. const blocked = !inert && composerBlock !== undefined const inputBar = renderSlot('conversation.composer.bar', { variant: hero ? 'hero' : 'composer', ...(inert ? { disabled: true, - placeholder: t(firstDraft.busy ? 'placeholder.preparing' : 'placeholder.workspace'), + placeholder: t('placeholder.workspace'), workspacePickerOpen: pickerOpen, - ...(needsWorkspace ? { onRequestWorkspace: () => { setPickerOpen(true) } } : {}), + onRequestWorkspace: () => { setPickerOpen(true) }, } : blocked // `blocked`, not `disabled`: the bar refuses input either way, but a @@ -192,23 +191,6 @@ export function ConversationContent(props: ConversationContentProps) { {composerSeat} - - - - - )} - > - {t('defaultWorkspace.retry')} - ) } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index f66ee7a96a..c57fe69936 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -1,5 +1,6 @@ -// Resident conversation shell keeps the composer mounted across local-draft, -// blank-Session, and active-conversation views. +// Resident conversation skeleton. Hero chrome, composer positioning, the +// chain, AND the composer bar (session-maybe slot) stay mounted across +// no-session/session transitions — the bar renders inert via owner props. import type { ConversationSlotProps } from '../contract/slots.ts' import { ConversationMainPanel } from './ConversationMainPanel.tsx' diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 5852e4b5c4..6a4ea12740 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -1,12 +1,16 @@ /** The default composer body: the 'conversation.composer.bar' slot entry. - * The injected input state and keyboard address the current Session or the - * first-use local draft. Standard Session props supply attachment actions; - * owner props control layout and availability. + * Machine state arrives through the standard provide channel + * (useInput + inputActions); the keyboard/DOM command face and stop arrive + * through this entry's own inject, whose hooks compartment binds + * useNotices/useLexicon; layout-phase inputs (variant and placeholder) ride + * the owner props. Session facts + * (running/removed/promptError) are self-selected via useSession. * * The text surface is the shell-owned Lexical editor bound here through * ComposerContentEditable; chips render as decorator portals, and the * keymap registers submit/menu/paste gestures on the editor command layer. - * The contenteditable remains mounted when the first Session arrives. + * The no-session state renders the SAME div inert as the Workspace-picker + * trigger instead of a parallel tree. */ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -38,7 +42,7 @@ import css from './InputBar.module.css' export type InputBarProps = ComposerBarProps export const InputBar = memo(function InputBar({ - useSession, useComposerInput, inputActions, keyboard, addFiles, removeAttachment, resolveDraftAttachments, + useSession, useInput, inputActions, keyboard, addFiles, removeAttachment, resolveDraftAttachments, retryFileUpload, toggleCommandMenu, stop, t, renderSlot, useBusyEnter, useFileUploads, useNotices, useLexicon, useMenuLauncher, @@ -46,7 +50,7 @@ export const InputBar = memo(function InputBar({ workspacePickerOpen = false, onRequestWorkspace, placeholder, accessory, }: InputBarProps) { - const input = useComposerInput(s => s) + const input = useInput(s => s) const notice = useNotices(s => s) const busyEnter = useBusyEnter(s => s) void useLexicon // hook seat stays bound by the inject compartment; text-ref decoration rides the shell's editor transforms @@ -60,11 +64,14 @@ export const InputBar = memo(function InputBar({ const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active)) // Absent (undefined: no frame yet) and cleared (null) both mean no goal. const hasGoal = useProjection('goal', goal => goal != null) - const draft = input.draft - const editor = keyboard.editor + // Session-maybe: the machine faces are absent together while no session is + // current; the bar renders the same DOM inert instead of a parallel tree. + const live = input !== undefined && keyboard !== undefined && inputActions !== undefined + const draft = input?.draft ?? '' + const editor = keyboard?.editor ?? null const attachments = useMemo( - () => resolveDraftAttachments === undefined ? [] : resolveDraftAttachments(input.attachmentIds), - [resolveDraftAttachments, input.attachmentIds], + () => input === undefined || resolveDraftAttachments === undefined ? [] : resolveDraftAttachments(input.attachmentIds), + [resolveDraftAttachments, input?.attachmentIds], ) const empty = draft.trim() === '' && attachments.length === 0 const uploads = useFileUploads(snapshot => snapshot) @@ -86,7 +93,8 @@ export const InputBar = memo(function InputBar({ // The deployment's image-intake limits (absent while no attachment service // is composed — the pre-check below then defers entirely to the host). const imageLimits = useProjection('imageLimits') - // The toast announces promptError, the draft stays in the machine, + // Prompt failures are ordinary failures (no create/attach transaction exists + // anymore): the toast announces promptError, the draft stays in the machine, // and the user resubmits. A remount over a session whose machine still holds // an unresolved promptError deliberately re-announces it once — the failure // is still pending, and a transient banner is its only surface. Attachment @@ -114,30 +122,35 @@ export const InputBar = memo(function InputBar({ // but its independent Stop below stays available while it runs. const continuable = subagent?.address.mode === 'continuable' const parentOffline = continuable && subagent.parentAvailable !== true - // Preparation, removed Sessions, and unavailable parents lock editing. - // Adjudicating and submitting keep the draft visible and read-only. - const disabled = removed || inert || blocked !== undefined || parentOffline + // Running input stays free; locked = session removed, the + // inert no-workspace state, the machine faces absent (no session), or a + // parent-offline continuable child. An owner block also disables input; + // adjudicating and submitting render read-only so the draft stays visible. + const disabled = removed || inert || !live || blocked !== undefined || parentOffline const locked = disabled // The model seat is the ONE control a block leaves live: every block this // contract has is cleared by choosing a model, so locking it too would leave // the composer asking for the only thing it prevents. The other reasons to // be disabled do lock it — there is no session to choose a model for. - const modelSeatLocked = removed || inert - const machineBusy = input.phase === 'adjudicating' || input.phase === 'submitting' - // A blank Session whose Workspace was deleted offers directory selection - // through the same resident input node. + const modelSeatLocked = removed || inert || !live + const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting' + // The no-workspace surface remains the resident DOM node but acts as the + // existing picker trigger. Message controls stay locked until a Session + // exists; the trigger itself is read-only rather than disabled so pointer + // and keyboard users can reach the recovery action. const workspaceTrigger = inert && !removed && onRequestWorkspace !== undefined const editorDisabled = removed || (locked && !workspaceTrigger) - const editable = !locked && !machineBusy + const editable = live && !locked && !machineBusy const steeringAvailable = subagent === null || subagent.address.mode === 'continuable' const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && steeringAvailable && input.queue.length > 0 useEffect(() => { - if (inputActions !== undefined && attachments.length !== input.attachmentIds.length) { + if (input === undefined || inputActions === undefined) return + if (attachments.length !== input.attachmentIds.length) { inputActions.pruneAttachments(attachments.map(attachment => attachment.id)) } - }, [attachments, input.attachmentIds, inputActions]) + }, [attachments, input?.attachmentIds, inputActions]) // Scroll the draft scrollport the minimum that brings the selection focus // into view — the browser's own behavior for typing, performed for the @@ -154,7 +167,7 @@ export const InputBar = memo(function InputBar({ // is ours to perform — switching to a longer draft otherwise leaves the // caret (restored at the draft's end) off screen. useEffect(() => { - if (locked) return + if (locked || editor === null) return focusDraftEditor(editor, revealSelection) }, [locked, sessionId, editor]) @@ -231,10 +244,12 @@ export const InputBar = memo(function InputBar({ } useEffect(() => { + if (keyboard === undefined) return return installDraftFilePicker(keyboard, gate, fileInputRef) }, [keyboard]) useEffect(() => { + if (editor === null || keyboard === undefined) return return installDraftKeymap(editor, keyboard, gate) }, [editor, keyboard]) @@ -247,16 +262,17 @@ export const InputBar = memo(function InputBar({ } const onToggleCommandMenu = (): void => { + if (keyboard === undefined) return // The menu is a combobox over the editor, so the keyboard has to be there // before the launcher opens it: activating the button from the keyboard // leaves focus on the button, and restoring it afterwards would re-track an // empty draft and close the menu again. - focusDraftEditor(editor, revealSelection) + if (editor !== null) focusDraftEditor(editor, revealSelection) toggleCommandMenu?.(keyboard.caretSpan()) } - // When a blank Session loses its Workspace, the resident editable div acts as the - // picker trigger for keyboard users. + // The no-session Workspace trigger: the resident editable div acts as the + // picker trigger for keyboard users (no editor is bound in this state). const onWorkspaceKeyDown = (e: KeyboardEvent): void => { if (!workspaceTrigger) return if (e.key === 'Enter' || e.key === ' ') { @@ -279,7 +295,7 @@ export const InputBar = memo(function InputBar({ const primaryDisabled = primaryStops ? stop === undefined : empty || disabled || machineBusy || uploadsPending const interruptible = running && continuable const primarySubmitMode = resolveSubmitMode(busyEnter, running, 'enter', steeringAvailable) - const plainMessageDraft = !empty && input.phase === 'plain' && !draft.trimStart().startsWith('/') + const plainMessageDraft = !empty && input?.phase === 'plain' && !draft.trimStart().startsWith('/') const primaryLabel = primaryStops ? t('input.stop') : running && steeringAvailable && !disabled && !uploadsPending && plainMessageDraft @@ -290,6 +306,7 @@ export const InputBar = memo(function InputBar({ stop?.() return } + if (keyboard === undefined) return // absent machine: the button is disabled /* v8 ignore next -- defensive: the primary button is disabled for empty, disabled, and pending-upload states. */ if (!empty && !disabled && !machineBusy && !uploadsPending) keyboard.submit(primarySubmitMode) } @@ -297,7 +314,7 @@ export const InputBar = memo(function InputBar({ // Claim ghost hint: rendered by CSS as generated content after the last // paragraph while the claim's args are blank (a hint implies a single-line // token draft). The translated per-command hint wins over the claim's own. - const claimActive = (input.phase === 'claimed' || input.phase === 'submitting') + const claimActive = (input?.phase === 'claimed' || input?.phase === 'submitting') && input.claim !== undefined && draft.startsWith(input.claim.token) const rawHint = claimActive && input.claim.hint !== undefined && draft.slice(input.claim.token.length).trim() === '' @@ -305,7 +322,7 @@ export const InputBar = memo(function InputBar({ : null const hint = ((): string | null => { if (rawHint === null) return null - const commandName = input.claim?.name ?? '' + const commandName = input?.claim?.name ?? '' const hintKey = `hint.${commandName === 'goal' && hasGoal ? 'goal.active' : commandName}` // Dynamic lookup by claimed command name: unknown commands miss the // dictionary and keep the machine's own hint, so the call is wide. @@ -379,7 +396,7 @@ export const InputBar = memo(function InputBar({ scrollRef={scrollRef} editable={editable} editorDisabled={editorDisabled} - phase={input.phase} + phase={input?.phase ?? 'inert'} placeholderText={placeholderText} ariaLabel={workspaceTrigger ? t('hero.chooseWorkspace') : placeholderText} workspaceTrigger={workspaceTrigger} @@ -416,12 +433,12 @@ export const InputBar = memo(function InputBar({ {sessionId === undefined ? null : renderSlot('conversation.input.permission', { locked })} {sessionId === undefined ? null : renderSlot('conversation.input.plan', { locked })} - {sessionId === undefined + {input === undefined || sessionId === undefined ? null : renderSlot('conversation.input.left', {})}
- {sessionId === undefined + {input === undefined || sessionId === undefined ? null : renderSlot('conversation.input.right', {})} {sessionId === undefined ? null : renderSlot('conversation.input.model', { locked: modelSeatLocked })} @@ -465,7 +482,7 @@ export const InputBar = memo(function InputBar({
- {variant === 'composer' && sessionId !== undefined + {variant === 'composer' && input !== undefined && sessionId !== undefined ? renderSlot('conversation.composer.dock', {}) : null} diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 0339ef042d..e90a11a163 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -2,7 +2,6 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { describe, expect, it, onTestFinished, vi } from 'vitest' import type { CommandContribution, CommandUiContract } from '@deepseek-ai/dsh-client-ui-commands/client' -import type { UiWorkspace } from '@deepseek-ai/dsh-client-ui-workspace/client' import type { ISession, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' @@ -74,14 +73,12 @@ async function bench() { opened(id) } const openSession = vi.fn((id: SessionId) => { replaceMain(id) }) - const openDefaultWorkspace = vi.fn(async () => undefined) runtime.ctx.provide('uiWorkspace', { openWorkspace: async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { const id = await connectWorkspace() replaceMain(id, beforeOpen) }, openSession, - openDefaultWorkspace, } as never) const sessionFake = sessionFakeFor() await runtime.sessions.add({ @@ -139,113 +136,10 @@ async function bench() { return { runtime, feature, slots: runtime.slots, entryOf, conversationApi, headerApi, residentApi, composerApi, inputApi, viewSource, sessionFake, connectWorkspace, rootUpload, uploads, rootReference, references, opened, - locale, openDefaultWorkspace, replaceMain, } } describe('Conversation inject API', () => { - it.each([false, true])('keeps the saved first draft after its Session retires (reopened during preparation: %s)', async (reopenEarly) => { - const b = await bench() - onTestFinished(() => b.runtime.dispose()) - const settings = Promise.withResolvers() - const started = Promise.withResolvers() - b.runtime.ctx.on('conversation/prepare-first-send', () => { - started.resolve(undefined) - return settings.promise - }) - b.openDefaultWorkspace.mockImplementation(async (_request, beforeOpen) => { - b.replaceMain(ROOT, beforeOpen) - return ROOT - }) - const body = b.conversationApi(ROOT) - const unmirror = body.injected.bindDraftMirror(body.instance.actions.setDraft) - const composer = b.composerApi(undefined) - let reopened: SessionReference | undefined - try { - composer.keyboard.paste('keep this first draft') - composer.keyboard.submit('queue') - await started.promise - expect(body.instance.store.getSnapshot().draft).toBe('keep this first draft') - const other = 'other-first-send' as SessionId - await b.runtime.sessions.add({ id: other, session: sessionFakeFor() }) - unmirror() - b.rootReference.release() - b.replaceMain(other) - await vi.waitFor(() => { expect(b.runtime.sessions.binding(ROOT)).toBeUndefined() }) - if (reopenEarly) reopened = b.runtime.sessions.retain(ROOT) - settings.resolve(undefined) - await vi.waitFor(() => { expect(b.residentApi(undefined).hooks.firstDraft.getSnapshot().busy).toBe(false) }) - await b.runtime.flush() - expect(b.sessionFake.prompt).not.toHaveBeenCalled() - reopened ??= b.runtime.sessions.retain(ROOT) - b.references.set(ROOT, reopened) - expect(b.conversationApi(ROOT).instance.store.getSnapshot().draft).toBe('keep this first draft') - expect(composer.hooks.composerInput.getSnapshot().draft).toBe('') - } finally { - settings.resolve(undefined) - reopened?.release() - unmirror() - } - }) - - it('submits the first draft through its retained destination input', async () => { - const b = await bench() - onTestFinished(() => b.runtime.dispose()) - b.openDefaultWorkspace.mockImplementation(async (_request, beforeOpen) => { - b.replaceMain(ROOT, beforeOpen) - return ROOT - }) - const composer = b.composerApi(undefined) - composer.keyboard.paste('first message') - composer.keyboard.submit('queue') - await vi.waitFor(() => { expect(b.sessionFake.prompt).toHaveBeenCalledOnce() }) - expect(b.sessionFake.prompt.mock.calls[0]?.[0]).toEqual([{ type: 'text', text: 'first message' }]) - expect(composer.hooks.composerInput.getSnapshot().draft).toBe('') - }) - - it.each([ - ['zh', '默认工作区', '默认工作区'], - ['en', 'Default workspace', 'Default workspace'], - ['ja', 'default-workspace', 'Default workspace'], - ])('resolves first-use names in the Client using the send-time locale %s', async (language, directoryName, title) => { - const b = await bench() - if (language === 'ja') b.locale.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' }) - const composer = b.composerApi(undefined) - composer.keyboard.paste('first message') - b.locale.setLocale(language) - composer.keyboard.submit('steer') - await vi.waitFor(() => { - expect(b.openDefaultWorkspace).toHaveBeenCalledWith({ directoryName, title }, expect.any(Function), expect.any(AbortSignal)) - expect(b.residentApi(undefined).hooks.firstDraft.getSnapshot().busy).toBe(false) - }) - }) - - it('keeps in-flight names fixed and resolves the new locale when retrying a failed first send', async () => { - const b = await bench() - const pending = Promise.withResolvers() - b.openDefaultWorkspace.mockReturnValueOnce(pending.promise) - const composer = b.composerApi(undefined) - b.locale.setLocale('en') - composer.keyboard.paste('keep this draft') - composer.keyboard.submit('steer') - try { - await vi.waitFor(() => { expect(b.openDefaultWorkspace).toHaveBeenCalledOnce() }) - b.locale.setLocale('zh') - expect(b.openDefaultWorkspace.mock.calls[0]?.[0]).toEqual({ directoryName: 'Default workspace', title: 'Default workspace' }) - pending.reject(new Error('directory unavailable')) - await vi.waitFor(() => { expect(b.residentApi(undefined).hooks.firstDraft.getSnapshot().busy).toBe(false) }) - expect(composer.hooks.composerInput.getSnapshot().draft).toBe('keep this draft') - composer.keyboard.submit('steer') - await vi.waitFor(() => { - expect(b.openDefaultWorkspace).toHaveBeenLastCalledWith({ directoryName: '默认工作区', title: '默认工作区' }, expect.any(Function), expect.any(AbortSignal)) - expect(b.residentApi(undefined).hooks.firstDraft.getSnapshot().busy).toBe(false) - }) - } finally { - pending.resolve(undefined) - await vi.waitFor(() => { expect(b.residentApi(undefined).hooks.firstDraft.getSnapshot().busy).toBe(false) }) - } - }) - it('owns the File action, reads its mounted composer availability, and unregisters on disposal', async () => { const b = await bench() onTestFinished(() => b.runtime.dispose()) @@ -265,7 +159,7 @@ describe('Conversation inject API', () => { expect(file.available({ sessionId: 'missing' as SessionId })).toBe(false) if (file.ui.kind !== 'action') throw new Error('File must be an action') file.ui.run({ sessionId: 'missing' as SessionId }) - const keyboard = b.composerApi(ROOT).keyboard + const keyboard = b.composerApi(ROOT).keyboard! const open = vi.fn() let available = true const unbind = keyboard.bindFilePicker({ open, available: () => available }) @@ -514,8 +408,7 @@ describe('Conversation inject API', () => { expect(() => { injectBar('ghost' as SessionId).stop!() }).toThrow(/resolved no binding/) const absent = injectBar(undefined) - expect(absent.keyboard).toBeDefined() - expect(absent.hooks.composerInput.getSnapshot().draft).toBe('') + expect(absent.keyboard).toBeUndefined() expect(absent.toggleCommandMenu).toBeUndefined() expect(absent.stop).toBeUndefined() expect(absent.hooks.notices.getSnapshot()).toBeNull() diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx index 51513cb28e..84e5ba28a1 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx @@ -100,10 +100,8 @@ async function bench(opts?: { blank?: boolean }) { } describe('resident composer', () => { - it('accepts a local draft before any Session exists and keeps the Workspace chip available', async () => { + it('renders the locked view state while no session exists at all', async () => { const runtime = await SlotTestRuntime.create() - runtime.sessions.list.set({ ...runtime.sessions.list.getSnapshot(), phase: 'pending' }) - await runtime.workspaces.update((draft) => { draft.phase = 'pending' }) provideWorkspaceNavigation(runtime) runtime.ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(runtime.ctx) @@ -115,21 +113,15 @@ describe('resident composer', () => { const view = runtime.renderRoot() const textarea = view.container.querySelector('[data-composer-input]') expect(textarea).not.toBeNull() - expect(textarea!.getAttribute('contenteditable')).toBe('false') - await runtime.workspaces.update((draft) => { draft.phase = 'ready' }) - expect(textarea!.getAttribute('contenteditable')).toBe('false') - await act(async () => { - runtime.sessions.list.set({ ...runtime.sessions.list.getSnapshot(), phase: 'ready' }) - }) expect(textarea!.getAttribute('aria-disabled')).not.toBe('true') - expect(textarea!.getAttribute('contenteditable')).toBe('true') - expect(textarea!.getAttribute('aria-haspopup')).toBeNull() + expect(textarea!.getAttribute('contenteditable')).not.toBe('true') + expect(textarea!.getAttribute('aria-haspopup')).toBe('menu') expect(view.getByTestId('workspace-probe').textContent).toBe('false:0') - fireEvent.click(view.getByRole('button', { name: '选择工作区' })) + fireEvent.click(textarea!) expect(view.getByTestId('workspace-probe').textContent).toBe('true:0') - expect(textarea!.getAttribute('aria-expanded')).toBeNull() - fireEvent.click(view.getByRole('button', { name: '选择工作区' })) + expect(textarea!.getAttribute('aria-expanded')).toBe('true') fireEvent.click(view.getByRole('button', { name: '选择工作区' })) + fireEvent.keyDown(textarea!, { key: 'Enter' }) expect(view.getByTestId('workspace-probe').textContent).toBe('true:0') expect(view.getByRole('button', { name: '选择工作区' })).toBeTruthy() await runtime.dispose() @@ -157,7 +149,7 @@ describe('resident composer', () => { const workspaceChip = view.getByRole('button', { name: '选择工作区' }) const workspaceProbe = view.getByTestId('workspace-probe') expect(textarea.getAttribute('aria-disabled')).not.toBe('true') - expect(textarea.getAttribute('contenteditable')).toBe('true') + expect(textarea.getAttribute('contenteditable')).not.toBe('true') fireEvent.click(workspaceChip) fireEvent.click(workspaceProbe) diff --git a/packages/client/ui-conversation/tests/first-draft.client.spec.ts b/packages/client/ui-conversation/tests/first-draft.client.spec.ts deleted file mode 100644 index b23055dcf1..0000000000 --- a/packages/client/ui-conversation/tests/first-draft.client.spec.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { Context } from '@deepseek-ai/cordis' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { SessionId } from '@deepseek-ai/dsh-session/types' -import { FirstDraft } from '../src/client/input/first-draft.ts' -import { SessionInputShell } from '../src/client/input/facade.ts' -import type { SubmitOutcome } from '../src/client/contract/input.ts' - -const contexts: Context[] = [] -afterEach(async () => { - for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() -}) - -function harness() { - const ctx = new Context() - contexts.push(ctx) - const opened = Promise.withResolvers() - ctx.effect(() => () => { opened.resolve(undefined) }) - const submitted = vi.fn(async (): Promise => ({ kind: 'success' })) - const target = new SessionInputShell({ - actx: ctx, - defaultSink: submitted, - commandAttachments: { serialize: async () => [], release: () => {}, unsupportedNotice: () => '' }, - }) - ctx.effect(() => () => { target.dispose() }) - let beforeOpen: ((id: SessionId) => void) | undefined - const open = vi.fn((prepare: (id: SessionId) => void) => { - beforeOpen = prepare - return opened.promise - }) - const first = new FirstDraft(ctx, { open, shell: () => target, isCurrent: () => true }) - return { - ctx, first, target, submitted, open, opened, - complete: () => { - const id = SessionId('default') - beforeOpen!(id) - opened.resolve(id) - }, - } -} - -describe('first message draft', () => { - it('retains a local draft without opening a Session until send', async () => { - const h = harness() - h.first.shell.setDraft('first message') - expect(h.open).not.toHaveBeenCalled() - h.first.shell.submit('steer') - expect(h.open).toHaveBeenCalledTimes(1) - expect(h.first.state.getSnapshot().busy).toBe(true) - h.first.shell.submit('steer') - expect(h.open).toHaveBeenCalledTimes(1) - h.complete() - await vi.waitFor(() => { expect(h.submitted).toHaveBeenCalledTimes(1) }) - expect(h.submitted.mock.calls[0]).toEqual(['first message', [], 'steer', expect.any(AbortSignal)]) - expect(h.first.state.getSnapshot()).toEqual({ busy: false, failed: false }) - }) - - it.each([false, true])('waits for first-use settings and keeps the Session draft on refusal (refused: %s)', async (refused) => { - const h = harness() - const settings = Promise.withResolvers() - const started = Promise.withResolvers() - h.ctx.on('conversation/prepare-first-send', (id) => { - started.resolve(id) - return settings.promise - }) - try { - h.first.shell.setDraft('use my selected mode') - h.first.shell.submit() - h.complete() - expect(await started.promise).toBe(SessionId('default')) - expect(h.submitted).not.toHaveBeenCalled() - if (refused) settings.reject(new Error('preset refused')) - else settings.resolve(undefined) - await vi.waitFor(() => { expect(h.first.state.getSnapshot().busy).toBe(false) }) - expect(h.first.state.getSnapshot().failed).toBe(false) - if (refused) { - expect(h.submitted).not.toHaveBeenCalled() - expect(h.target.snapshot.draft).toBe('use my selected mode') - expect(h.target.notices.getSnapshot()?.text).toBe('preset refused') - } else { - expect(h.submitted).toHaveBeenCalledOnce() - } - } finally { - settings.resolve(undefined) - await vi.waitFor(() => { expect(h.first.state.getSnapshot().busy).toBe(false) }) - } - }) - - it('restores input on preparation failure and dismisses the modal without losing it', async () => { - const h = harness() - h.first.shell.setDraft('preserve me') - h.first.shell.submit() - h.opened.reject(new Error('permission denied')) - await vi.waitFor(() => { expect(h.first.shell.snapshot.draft).toBe('preserve me') }) - expect(h.first.state.getSnapshot()).toEqual({ busy: false, failed: true }) - expect(h.submitted).not.toHaveBeenCalled() - h.first.dismiss() - expect(h.first.state.getSnapshot().failed).toBe(false) - expect(h.first.shell.snapshot.draft).toBe('preserve me') - }) - - it('hands a draft to a manual selection without sending it', async () => { - const h = harness() - h.first.shell.setDraft('manual folder') - h.first.transfer(SessionId('manual')) - expect(h.target.snapshot.draft).toBe('manual folder') - expect(h.first.shell.snapshot.draft).toBe('') - expect(h.submitted).not.toHaveBeenCalled() - }) - - it('lets manual navigation take the pending draft and cancels automatic submission', async () => { - const h = harness() - h.first.shell.setDraft('pending text') - h.first.shell.submit() - h.first.transfer(SessionId('manual')) - h.opened.resolve(undefined) - await vi.waitFor(() => { expect(h.first.state.getSnapshot().busy).toBe(false) }) - expect(h.target.snapshot.draft).toBe('pending text') - expect(h.first.shell.snapshot.draft).toBe('') - expect(h.submitted).not.toHaveBeenCalled() - }) - - it('restores a superseded draft without a directory error', async () => { - const h = harness() - h.first.shell.setDraft('later') - h.first.shell.submit() - h.opened.resolve(undefined) - await vi.waitFor(() => { expect(h.first.shell.snapshot.draft).toBe('later') }) - expect(h.first.state.getSnapshot().failed).toBe(false) - }) - - it('leaves ordinary send failures in the selected Session input', async () => { - const h = harness() - h.submitted.mockResolvedValue({ kind: 'error' }) - h.first.shell.setDraft('retry the prompt') - h.first.shell.submit() - h.complete() - await vi.waitFor(() => { expect(h.target.snapshot.draft).toBe('retry the prompt') }) - expect(h.first.shell.snapshot.draft).toBe('') - expect(h.first.state.getSnapshot().failed).toBe(false) - }) -}) diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index a4805db1ec..9e0366952c 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -185,7 +185,6 @@ function bench(over?: BenchOptions) { ? over?.plan : key === 'goal' ? over?.goal : key === 'imageLimits' ? over?.imageLimits : undefined)), - useComposerInput: bindSnapshotSelector(shell.state), useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index c8641afcb4..15d87a0ca7 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -67,7 +67,6 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled })), useProjection: (() => undefined), useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), - useComposerInput: bindSnapshotSelector(shell.state), useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 0c098d95fb..347b4d05c1 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -161,7 +161,6 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo })), useProjection: (() => undefined), useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), - useComposerInput: bindSnapshotSelector(shell.state), useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 619e3fddff..17aadfb056 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -137,8 +137,6 @@ function mount( nestedSubagent?: boolean /** A composer block another plugin raised for this session. */ composerBlock?: { reason: string } - /** First-use preparation remains active after selecting its Session. */ - preparingFirstDraft?: boolean /** Mutable view ledger used by registration-order regressions. */ viewTabs?: ViewTab[] } = {}, @@ -282,7 +280,6 @@ function mount( useProjection={(() => undefined)} useInput={useInput} inputActions={inputActions} - useComposerInput={useInput} keyboard={wiring} addFiles={() => null} useFileUploads={bindSnapshotSelector(createSnapshotStore({}))} @@ -339,8 +336,6 @@ function mount( useWorkspaces: bindSnapshotSelector(workspaces), useProjection: (() => undefined), useComposerBlock: select => select(options.composerBlock), - useFirstDraft: select => select({ busy: options.preparingFirstDraft === true, failed: false }), - dismissDefaultFailure: () => {}, useInput, inputActions, renderSlot, @@ -410,18 +405,6 @@ describe('ConversationRoot resident composer', () => { expect(b.view.queryByTestId('view-conversation.session.header.corner')).toBeNull() }) - it('locks the selected Session draft until automatic first-use preparation settles', () => { - const b = mount(sessionSnapshotOf({ blank: true }), undefined, undefined, { - summaryBlank: true, preparingFirstDraft: true, - }) - const box = b.view.getByRole('textbox') - expect(box.getAttribute('aria-disabled')).toBe('true') - expect(box.getAttribute('aria-label')).toBe('正在准备工作区…') - expect(box.getAttribute('aria-haspopup')).toBeNull() - fireEvent.keyDown(box, { key: 'Enter' }) - expect(b.sink).not.toHaveBeenCalled() - }) - it('does not redispatch composer child slots for an unrelated Session publication', () => { const b = mount(sessionSnapshotOf()) const childKeys = new Set([ diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index b98485e434..4af8adc8d4 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/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-workspace/README.md -README.md: 03dc8a9ff45fbc1fc1ab8585db15c19437807f5e -README.zh.md: ac8000f78d3a9394ec2c83790aa3e0349cac2ab0 +README.md: 768c66503af70d0b587b25c09aa8412b0a647439 +README.zh.md: 25df6f8e6a2ec0a001d784c7bde18e454e9d706f diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 03dc8a9ff4..768c66503a 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -69,7 +69,9 @@ The value is intentionally best effort for cold Sessions. An identity-matching u New Session tries to acquire the first eligible blank in catalog order; startup restoration tries the saved blank. If that writer is held, navigation creates a new Session without trying other blanks. Other acquisition failures abort the request and are currently reported only to the console. Released blanks retain their slash-command state when reused. Later navigation cancels a pending startup selection. -`openDefaultWorkspace(request, beforeOpen, signal)` initializes the Host default Workspace from the requested directory name and title before connecting its Session. It captures navigation cancellation before directory preparation; superseded work cannot transfer the draft or select its Session. A successfully registered Workspace remains available when Session creation or later submission fails. [Conversation](../ui-conversation/README.md) owns the first-use draft and failure dialog. +Once both Workspace and Session startup baselines are ready, an empty installation calls `workspaces.initializeDefault` and creates or reuses its blank Session. The composer becomes editable when that Session is selected; no message is submitted automatically. Later navigation or owner disposal prevents startup from selecting its result. Ineligible first use leaves the folder picker available without an error. Preparation failure offers Cancel or Choose folder through the existing picker, and is not retried until the next startup. A successfully registered Workspace remains available when Session creation or later submission fails. + +The Client chooses the initial directory name and title from its language at startup: Chinese uses `默认工作区`, English uses `Default workspace`, and other languages use directory `default-workspace` with title `Default workspace`. The request retains those names during initialization. A successfully initialized Workspace keeps its directory and title across language changes. ## Understand the implementation diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index ac8000f78d..25df6f8e6a 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -69,7 +69,9 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示** 新建会话尝试获取列表中第一个符合条件的空白会话;启动恢复尝试已保存的空白会话。若该写锁被占用,导航直接新建 Session,不再尝试其他空白会话。其他获取错误会中止请求,目前只报告到控制台。已释放的空白会话被复用时保留 slash 命令状态。后续导航会取消尚未完成的启动选择。 -`openDefaultWorkspace(request, beforeOpen, signal)` 在连接 Session 前,按请求中的目录名和标题初始化 Host 默认工作区。它在目录准备之前捕获导航取消信号;被取代的操作不能转移草稿或选中 Session。登记成功的工作区在 Session 创建或后续提交失败时仍然保留。[Conversation](../ui-conversation/README.zh.md) 负责首次使用草稿和失败弹窗。 +Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `workspaces.initializeDefault`,创建或复用其空白 Session。选中该 Session 后输入框才可编辑,不会自动提交消息。后续导航或所属上下文销毁会阻止启动流程选中其结果。不符合首次使用条件时仍可选择文件夹,不显示错误。准备失败时通过现有选择器提供取消或选择文件夹操作,直到下次启动才重试。登记成功的工作区在 Session 创建或后续提交失败时仍然保留。 + +Client 在启动时按其语言选择初始目录名和标题:中文使用 `默认工作区`,英文使用 `Default workspace`,其他语言使用目录 `default-workspace` 和标题 `Default workspace`。初始化过程中保留请求中的名称。成功初始化的工作区在切换语言后保留原目录和标题。 ## 理解实现 diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index b74e3689dc..42e177afce 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -231,21 +231,45 @@ export function WorkspacePicker({ onClose, createWorkspace, useDirectoryFlow, + useDefaultFailure, + dismissDefaultFailure, renderSlot, t, }: WorkspacePickerProps) { + const failed = useDefaultFailure(value => value) + const flowAvailable = useDirectoryFlow(value => value) + const [recovering, setRecovering] = useState(false) return ( - renderSlot('conversation.hero.workspace.directoryFlow', owner)} - selectedId={selectedId} - onPick={onPick} - onClose={onClose} - /> + <> + renderSlot('conversation.hero.workspace.directoryFlow', owner)} + selectedId={selectedId} + onPick={onPick} + onClose={() => { setRecovering(false); onClose() }} + /> + + + + + )} + > + {t('defaultWorkspace.retry')} + + ) } diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index 6a54d79146..b878e24986 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -364,6 +364,12 @@ export type WorkspaceBrowserProps = * supplies the implicit index signature required by the registry. */ export type WorkspacePickerInjected = DirectoryPickingInjected & { + /** Dismiss the startup failure before choosing a directory manually. */ + dismissDefaultFailure: () => void + hooks: { + /** Whether first-use startup failed to prepare a Workspace and Session. */ + defaultFailure: HostObservable + } /** Adopt a picked host directory as a real Workspace before targeting a Session. */ createWorkspace: (input: { path: string }) => Promise } @@ -377,5 +383,5 @@ export type WorkspacePickerProps = PropsRuntime<'conversation.hero.workspace'> & PropsRenderSlots<'conversation.hero.workspace.directoryFlow'> & Omit - & DirectoryPickingHooks + & PropsHooks & PropsLocale<'workspace'> diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 0614dea6a4..8d96c65d77 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -219,7 +219,8 @@ export function apply(ctx: Context): void { }) const pickerInjected = (): WorkspacePickerInjected => ({ createWorkspace: input => workspaces.create(input), - hooks: { directoryFlow: pickerFlowSource }, + dismissDefaultFailure: () => { uiWorkspace.defaultFailure.set(false) }, + hooks: { directoryFlow: pickerFlowSource, defaultFailure: uiWorkspace.defaultFailure }, }) // Each registration declares its owned children in the same call; slot // injection follows both the owner and declaration HMR lifetimes. diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index e721af231f..d926ce987f 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -6,6 +6,10 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'defaultWorkspace.failed': '无法创建默认工作区', + 'defaultWorkspace.title': '默认工作区', + 'defaultWorkspace.retry': '请选择一个文件夹后重试', + 'defaultWorkspace.choose': '选择文件夹', 'group.ungrouped': '未分组', 'session.new': '新会话', 'section.workspaces': '工作区', @@ -99,6 +103,10 @@ export type WorkspaceKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { + 'defaultWorkspace.failed': 'Unable to create default workspace', + 'defaultWorkspace.title': 'Default workspace', + 'defaultWorkspace.retry': 'Please choose a folder and try again', + 'defaultWorkspace.choose': 'Choose folder', 'group.ungrouped': 'Ungrouped', 'session.new': 'New Session', 'section.workspaces': 'Workspaces', diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index 29c4c8d3f7..b21cf06f15 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -12,10 +12,12 @@ import type { import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { - IWorkspaces, WorkspaceId, WorkspaceInitializeDefaultRequest, WorkspaceSnapshot, WorkspaceView, + IWorkspaces, WorkspaceId, WorkspaceSnapshot, WorkspaceView, } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import { en, zh } from './locales.ts' import { pinOrderAccounts, pinOrderSource } from './pin-order.ts' import type { WorkspaceViewStoreActions } from './stores.ts' @@ -38,18 +40,6 @@ export interface UiWorkspace { * @returns completion; a superseded request may create a Session but does not open it. */ openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise - /** - * Prepare the first-use Workspace and select its blank Session. - * @param request - initial directory name and title. - * @param beforeOpen - synchronous draft transfer after retaining the Session. - * @param signal - caller cancellation. - * @returns selected Session, or undefined after navigation supersession. - */ - openDefaultWorkspace( - request: WorkspaceInitializeDefaultRequest, - beforeOpen: (sessionId: SessionId) => void, - signal: AbortSignal, - ): Promise /** * Fork a Session without changing the current selection. * @param sessionId - source Session. @@ -130,6 +120,8 @@ export class DirectoryBrowseError extends Error { /** Implements Workspace archive and directory UI operations. */ class UiWorkspaceService extends Service implements UiWorkspace { + /** Startup failure awaiting dismissal or explicit directory selection. */ + readonly defaultFailure = createSnapshotStore(false) private readonly connecting = new Map>() private readonly lifetime = new AbortController() private readonly selection = createSnapshotStore( @@ -213,25 +205,6 @@ class UiWorkspaceService extends Service implements UiWorkspace { this.replaceMain(sessionId, navigation, 'reveal', beforeOpen) } - async openDefaultWorkspace( - request: WorkspaceInitializeDefaultRequest, - beforeOpen: (sessionId: SessionId) => void, - signal: AbortSignal, - ): Promise { - const navigation = AbortSignal.any([this.ctx.layout.beginNavigation(), this.lifetime.signal, signal]) - try { - const workspace = await this.workspaces.initializeDefault(request, navigation) - navigation.throwIfAborted() - const sessionId = await this.connectWorkspace(workspace.workspaceId) - navigation.throwIfAborted() - this.replaceMain(sessionId, navigation, 'reveal', beforeOpen) - return sessionId - } catch (error) { - if (navigation.aborted) return undefined - throw error - } - } - async forkSession(sessionId: SessionId): Promise { await this.sessions.fork({ sessionId, increaseTitle: true }) } @@ -353,11 +326,30 @@ class UiWorkspaceService extends Service implements UiWorkspace { } const target = workspace?.workspaceId ?? recentWorkspace(workspaces.items, sessions.byId) if (sessionId === undefined && target !== undefined) sessionId = await this.connectWorkspace(target) + if (target === undefined && workspaces.items.length === 0 && sessions.ids.length === 0) { + sessionId = await this.initializeDefaultWorkspace(navigation) + } if (sessionId !== undefined && !navigation.aborted) { this.replaceMain(sessionId, navigation, 'preserve') } } + private async initializeDefaultWorkspace(signal: AbortSignal): Promise { + const language = this.ctx.locale.getSnapshot().active.toLowerCase().split('-')[0] + const title = (language === 'zh' ? zh : en)['defaultWorkspace.title'] + try { + const workspace = await this.workspaces.initializeDefault({ + directoryName: language === 'zh' || language === 'en' ? title : 'default-workspace', + title, + }, signal) + if (workspace === undefined || signal.aborted) return undefined + return await this.connectWorkspace(workspace.workspaceId) + } catch (_error: unknown) { + if (!signal.aborted) this.defaultFailure.set(true) + return undefined + } + } + /** @returns true when an archived current selection was cleared. */ private clearArchivedCurrent(): boolean { const current = this.mainReference?.sessionId diff --git a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx index 9ff0181207..e72d523668 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { @@ -88,14 +89,19 @@ function mount( items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn(), occupancy = occupancySource(), + options: { defaultFailure?: boolean; open?: boolean } = {}, ) { + const failure = createSnapshotStore(options.defaultFailure ?? false) + const useDefaultFailure = bindSnapshotSelector(failure) + const dismissDefaultFailure = vi.fn(() => { failure.set(false) }) const onPick = vi.fn() const onClose = vi.fn() const anchorRef = anchor() const { probe, renderSlot } = flowProbe() const renderPicker = (nextItems: readonly WorkspaceView[]) => ( { view.rerender(renderPicker(nextItems)) }, } } @@ -220,6 +226,7 @@ describe('WorkspacePicker', () => { const { renderSlot } = flowProbe() render( undefined} @@ -238,6 +245,7 @@ describe('WorkspacePicker', () => { const { renderSlot } = flowProbe() render( undefined} @@ -321,3 +329,26 @@ describe('WorkspacePicker', () => { expect(screen.queryByRole('menuitem', { name: '添加工作区…' })).toBeNull() }) }) + +describe('default Workspace startup recovery', () => { + it('dismisses the failure without opening the directory flow', () => { + const b = mount([], vi.fn(), occupancySource(), { defaultFailure: true, open: false }) + expect(screen.getByRole('dialog', { name: '无法创建默认工作区' })).toBeTruthy() + expect(b.probe.owner?.open).toBe(false) + fireEvent.click(screen.getByRole('button', { name: '取消' })) + expect(b.dismissDefaultFailure).toHaveBeenCalledOnce() + expect(screen.queryByRole('dialog', { name: '无法创建默认工作区' })).toBeNull() + expect(b.probe.owner?.open).toBe(false) + }) + + it('opens the composed directory flow from the startup failure', async () => { + const create = vi.fn(async () => workspace('chosen')) + const b = mount([], create, occupancySource(), { defaultFailure: true, open: false }) + fireEvent.click(screen.getByRole('button', { name: '选择文件夹' })) + expect(b.dismissDefaultFailure).toHaveBeenCalledOnce() + expect(b.probe.owner?.open).toBe(true) + await act(async () => { b.probe.owner!.onPicked('/chosen') }) + expect(create).toHaveBeenCalledExactlyOnceWith({ path: '/chosen' }) + expect(b.onPick).toHaveBeenCalledExactlyOnceWith(wid('chosen')) + }) +}) diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index 422de2dc2d..4f9cd973b8 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -1,4 +1,6 @@ +import { setImmediate } from 'node:timers/promises' import { Context } from '@deepseek-ai/cordis' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { afterEach, describe, expect, it, vi } from 'vitest' import type { ISessions, SessionListState, SessionReference, SessionSummary, @@ -169,11 +171,7 @@ class FakeSessions implements ISessions { } class FakeWorkspaces implements IWorkspaces { - readonly initializeDefault = vi.fn(async () => { - const item = workspace('default') - this.list.set(workspaceState([item])) - return item - }) + readonly initializeDefault = vi.fn(async () => undefined) readonly list: MutableSource readonly archiveCalls: SessionId[] = [] readonly unarchiveCalls: SessionId[] = [] @@ -268,6 +266,8 @@ class FakeDirectoryPicker { } interface BenchOptions { + readonly language?: string + readonly configureWorkspaces?: (workspaces: FakeWorkspaces) => void readonly workspaces?: WorkspaceSnapshot readonly sessions?: SessionListState readonly configureSessions?: (sessions: FakeSessions) => void @@ -276,6 +276,10 @@ interface BenchOptions { function bench(options: BenchOptions = {}) { const ctx = new Context() contexts.push(ctx) + const locale = new LocaleRuntime(ctx) + if (options.language === 'fr') locale.addLanguage({ id: 'fr', label: 'Français', fallback: 'en' }) + locale.setLocale(options.language ?? 'en') + ctx.provide('locale', locale) const layout = new LayoutController({ selectPanel: vi.fn(), retainMainPanels: vi.fn(), setSidebar: vi.fn(), toggleSidebar: vi.fn(), setViewportWidth: vi.fn(), @@ -287,6 +291,7 @@ function bench(options: BenchOptions = {}) { const directoryPicker = new FakeDirectoryPicker() const workspaces = new FakeWorkspaces(options.workspaces ?? workspaceState([], [], 'pending')) const sessions = new FakeSessions(options.sessions ?? sessionState([], 'pending')) + options.configureWorkspaces?.(workspaces) options.configureSessions?.(sessions) const view = createWorkspaceViewStore().create() const uiWorkspace = new UiWorkspaceService( @@ -300,39 +305,95 @@ function bench(options: BenchOptions = {}) { } describe('UiWorkspaceService', () => { - it('prepares the default Workspace before creating and retaining its Session', async () => { - const b = bench({ workspaces: workspaceState(), sessions: sessionState() }) - const beforeOpen = vi.fn(() => { - expect(b.sessions.retain).toHaveBeenCalledWith(sid('created-default'), { source: 'mainView' }) + it.each([ + ['zh', '默认工作区', '默认工作区'], + ['en', 'Default workspace', 'Default workspace'], + ['fr', 'default-workspace', 'Default workspace'], + ])('prepares and selects the default Workspace after both startup baselines (%s)', async (language, directoryName, title) => { + const b = bench({ language, configureWorkspaces: (workspaces) => { + workspaces.initializeDefault.mockImplementation(async () => { + const item = workspace('default') + workspaces.list.set(workspaceState([item])) + return item + }) + } }) + expect(b.workspaces.initializeDefault).not.toHaveBeenCalled() + b.sessions.list.set(sessionState()) + expect(b.workspaces.initializeDefault).not.toHaveBeenCalled() + b.workspaces.list.set(workspaceState()) + await vi.waitFor(() => { + expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(sid('created-default'), { source: 'mainView' }) }) - await expect(b.uiWorkspace.openDefaultWorkspace({ directoryName: '默认工作区', title: '默认工作区' }, beforeOpen, new AbortController().signal)) - .resolves.toBe(sid('created-default')) - expect(b.workspaces.initializeDefault).toHaveBeenCalledWith({ directoryName: '默认工作区', title: '默认工作区' }, expect.any(AbortSignal)) + expect(b.workspaces.initializeDefault).toHaveBeenCalledExactlyOnceWith({ directoryName, title }, expect.any(AbortSignal)) expect(b.sessions.create).toHaveBeenCalledWith({ workspaceId: wid('default') }) - expect(beforeOpen).toHaveBeenCalledWith(sid('created-default')) + expect(b.uiWorkspace.defaultFailure.getSnapshot()).toBe(false) }) - it('does not create a Session when default preparation fails or is superseded', async () => { + it('leaves an ineligible empty installation without a Session or failure dialog', async () => { const b = bench({ workspaces: workspaceState(), sessions: sessionState() }) - b.workspaces.initializeDefault.mockRejectedValueOnce(new Error('denied')) - await expect(b.uiWorkspace.openDefaultWorkspace({ directoryName: 'Default workspace', title: 'Default workspace' }, vi.fn(), new AbortController().signal)).rejects.toThrow('denied') + await setImmediate() + b.workspaces.list.set(workspaceState()) + b.sessions.list.set(sessionState()) + await setImmediate() + expect(b.workspaces.initializeDefault).toHaveBeenCalledOnce() expect(b.sessions.create).not.toHaveBeenCalled() + expect(b.uiWorkspace.defaultFailure.getSnapshot()).toBe(false) + }) + + it('requires explicit selection when the startup Session list contains history', async () => { + const b = bench({ workspaces: workspaceState(), sessions: sessionState([summary('history')]) }) + await setImmediate() + expect(b.workspaces.initializeDefault).not.toHaveBeenCalled() + expect(b.sessions.create).not.toHaveBeenCalled() + }) + + it('publishes a startup directory failure once without creating a Session', async () => { + const b = bench({ workspaces: workspaceState(), sessions: sessionState(), configureWorkspaces: (workspaces) => { + workspaces.initializeDefault.mockRejectedValueOnce(new Error('denied')) + } }) + await vi.waitFor(() => { expect(b.uiWorkspace.defaultFailure.getSnapshot()).toBe(true) }) + b.uiWorkspace.defaultFailure.set(false) + b.workspaces.list.set(workspaceState()) + await setImmediate() + expect(b.workspaces.initializeDefault).toHaveBeenCalledOnce() + expect(b.sessions.create).not.toHaveBeenCalled() + expect(b.uiWorkspace.defaultFailure.getSnapshot()).toBe(false) + }) + + it.each(['session', 'panel', 'disposal'] as const)('cancels startup directory preparation after %s navigation', async (kind) => { const pending = Promise.withResolvers() - b.workspaces.initializeDefault.mockReturnValueOnce(pending.promise) - const beforeOpen = vi.fn() - const opening = b.uiWorkspace.openDefaultWorkspace({ directoryName: 'Default workspace', title: 'Default workspace' }, beforeOpen, new AbortController().signal) - b.uiWorkspace.openSession(sid('manual')) + const b = bench({ workspaces: workspaceState(), sessions: sessionState(), configureWorkspaces: (workspaces) => { + workspaces.initializeDefault.mockReturnValueOnce(pending.promise) + } }) + if (kind === 'session') b.uiWorkspace.openSession(sid('manual')) + else if (kind === 'panel') b.layout.selectPanel('other-panel' as MainPanelId) + else await b.ctx.fiber.dispose() + expect(b.workspaces.initializeDefault.mock.calls[0]![1]?.aborted).toBe(true) pending.resolve(workspace('default')) - await expect(opening).resolves.toBeUndefined() + await setImmediate() expect(b.sessions.create).not.toHaveBeenCalled() - expect(beforeOpen).not.toHaveBeenCalled() + expect(b.sessions.retain.mock.calls.map(args => args[0])).toEqual(kind === 'session' ? [sid('manual')] : []) + expect(b.uiWorkspace.defaultFailure.getSnapshot()).toBe(false) }) - it('retains the prepared Workspace if creating its Session fails', async () => { - const b = bench({ workspaces: workspaceState(), sessions: sessionState() }) - b.sessions.create.mockRejectedValueOnce(new Error('session failed')) - await expect(b.uiWorkspace.openDefaultWorkspace({ directoryName: 'Default workspace', title: 'Default workspace' }, vi.fn(), new AbortController().signal)).rejects.toThrow('session failed') - expect(b.workspaces.list.getSnapshot().items).toEqual([workspace('default')]) + it('retains a prepared Workspace when its Session fails and suppresses a superseded failure', async () => { + for (const superseded of [false, true]) { + const created = Promise.withResolvers() + const b = bench({ workspaces: workspaceState(), sessions: sessionState(), configureWorkspaces: (workspaces) => { + workspaces.initializeDefault.mockImplementationOnce(async () => { + const item = workspace('default') + workspaces.list.set(workspaceState([item])) + return item + }) + }, configureSessions: (sessions) => { sessions.create.mockReturnValueOnce(created.promise) } }) + await vi.waitFor(() => { expect(b.sessions.create).toHaveBeenCalledOnce() }) + if (superseded) b.layout.selectPanel('other-panel' as MainPanelId) + created.reject(new Error('session failed')) + await setImmediate() + expect(b.workspaces.list.getSnapshot().items).toEqual([workspace('default')]) + expect(b.uiWorkspace.defaultFailure.getSnapshot()).toBe(!superseded) + expect(b.sessions.retain).not.toHaveBeenCalled() + } }) it('retains an explicit main target before revealing its Conversation', () => { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index b89f2f3ada..37f5d7ec0e 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -3251,10 +3251,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the Workspace and whether this call created it.', }, { - signature: '@Remote(\'initializeDefault\') async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise', - description: 'Initialize or reuse the default Workspace before the first user message.', + signature: '@Remote(\'initializeDefault\') async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise', + description: 'Initialize or reuse the default Workspace during first-use startup.', parameters: [{ name: 'request', description: 'initial directory name and title; never rename an existing default.' }, { name: 'signal', description: 'caller lifetime; cancels native directory lookup.' }], - returns: 'the durable Workspace without creating a Session or sending a message.', + returns: 'the durable Workspace, or undefined when first-use initialization is ineligible; creates no Session or message.', }, { signature: '@Remote(\'rename\') rename(request: WorkspaceRenameRequest): Promise', diff --git a/packages/test-support/client-runtime/src/workspaces.ts b/packages/test-support/client-runtime/src/workspaces.ts index 2c6b788fa3..63c1ec78d9 100644 --- a/packages/test-support/client-runtime/src/workspaces.ts +++ b/packages/test-support/client-runtime/src/workspaces.ts @@ -81,16 +81,15 @@ export class TestWorkspaces implements IWorkspaces { } /** - * Initialize the default Workspace through an explicitly installed test stub. + * Initialize the default Workspace through a test stub; defaults to an ineligible first use. * @param request - initial directory name and title. * @param signal - caller lifetime. - * @returns the stubbed Workspace. + * @returns the stubbed Workspace, or undefined when initialization is ineligible. */ - async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise { + async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise { this.calls.push({ method: 'initializeDefault', args: [request, signal] }) const stub = this.stubs.get('initializeDefault') - if (stub === undefined) throw new Error('initializeDefault requires a Workspace fixture') - return await (stub(request, signal) as Promise) + return await (stub?.(request, signal) as Promise | undefined) } /** diff --git a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx index aeff2554d1..ca9523edbe 100644 --- a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx +++ b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx @@ -557,12 +557,12 @@ describe('workspaces', () => { expect(view.container.textContent).toContain('ws:pending') await runtime.dispose() }) - it('requires an explicit default Workspace fixture and forwards its request and lifetime', async () => { + it('skips default initialization without a fixture and forwards a configured request and lifetime', async () => { const runtime = await SlotTestRuntime.create() try { const request = { directoryName: 'default-workspace', title: 'Default workspace' } const signal = new AbortController().signal - await expect(runtime.workspaces.initializeDefault(request, signal)).rejects.toThrow('requires a Workspace fixture') + await expect(runtime.workspaces.initializeDefault(request, signal)).resolves.toBeUndefined() const workspace = { workspaceId: 'default' as WorkspaceId, title: request.title, path: '/default', sessionIds: [], createdAt: '2026-09-20T00:00:00Z', updatedAt: '2026-09-20T00:00:00Z', diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 1f19959e7e..adababb1a4 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -246,7 +246,6 @@ export const EVENT_SCOPE_PAGE: Record = { export const EVENT_WALK_EXEMPTIONS: Record = { 'command/executed': 'client-face local command acknowledgment — packages/client/ui-commands/README.md owns the API', 'connection/reset': 'client-face transport signal — packages/api/session-controller/README.md owns the API', - 'conversation/prepare-first-send': 'client-face first-use draft preparation — packages/client/ui-conversation/README.md owns the API', 'locale/change': 'client-face locale switch signal — packages/client/locale/README.md owns the API', 'slash/input-begin-command': 'client-face slash-input protocol — packages/client/ui-input-trigger/README.md owns the API', 'slash/input-consume-token': 'client-face slash-input protocol — packages/client/ui-input-trigger/README.md owns the API', From 3a7f8f9657c1516aab510fbadecbc3e10152185d Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sun, 20 Sep 2026 17:20:44 +0800 Subject: [PATCH 09/60] test(web): restore ordinary workspace picker readiness --- apps/web/tests/preview-boot.e2e.ts | 4 ++-- apps/web/tests/sessionless-header.e2e.ts | 2 +- apps/web/tests/startup-rpc-budget.e2e.ts | 2 +- apps/web/tests/support.ts | 23 ++++++++++++++++------- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index 4797a6e4bf..bde54fe69e 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -297,7 +297,7 @@ async function bootPreview(origin: string, browser: Browser): Promise { expect(await page.getByText('Empty environment', { exact: true }).count()).toBe(1) expect(await page.getByText('WebFS directory', { exact: true }).count()).toBe(1) expect(await page.locator('input[name="preview-source"][value="webfs"]').isDisabled()).toBe(true) - expect(await page.getByRole('button', { name: 'Choose workspace' }).count()).toBe(0) + expect(await page.getByRole('textbox', { name: 'Choose workspace' }).count()).toBe(0) await compareOrRefreshGolden( SOURCE_CHOOSER_EXPECTED, await captureStableAria(page, '[data-preview-source-card]', '/__preview_no_workspace__'), @@ -471,7 +471,7 @@ async function bootEmptyPreview(origin: string, browser: Browser): Promise ) expect(bootLine).toContain(`image lowering=${WRAPPER_CONTRACT}`) expect(bootLine).toContain('data overlays=0') - await page.getByRole('button', { name: 'Choose workspace' }).waitFor({ timeout: HERO_TIMEOUT_MS }) + await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: HERO_TIMEOUT_MS }) const sessionCount = await page.evaluate(async () => { const transport = (globalThis as typeof globalThis & { __DSH_TRANSPORT__?: { fetch(input: string, init: RequestInit): Promise } diff --git a/apps/web/tests/sessionless-header.e2e.ts b/apps/web/tests/sessionless-header.e2e.ts index 1de5dfe14f..b2c1a85fb0 100644 --- a/apps/web/tests/sessionless-header.e2e.ts +++ b/apps/web/tests/sessionless-header.e2e.ts @@ -30,7 +30,7 @@ describe('navigation without a selected Session', () => { } const tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await page.getByRole('button', { name: 'Choose workspace', exact: true }).waitFor() + await page.getByRole('textbox', { name: 'Choose workspace', exact: true }).waitFor() const sessionHeader = page.locator('[data-slot="conversation.session.header"]') expect(await sessionHeader.count()).toBe(0) const header = page.locator('[data-slot="conversation.header"] > header') diff --git a/apps/web/tests/startup-rpc-budget.e2e.ts b/apps/web/tests/startup-rpc-budget.e2e.ts index d8c89afcca..f6655ffbf3 100644 --- a/apps/web/tests/startup-rpc-budget.e2e.ts +++ b/apps/web/tests/startup-rpc-budget.e2e.ts @@ -36,7 +36,7 @@ describe('startup RPC budget', () => { await page.goto(scaffold.authenticatedUrl) // Boot settles when the workspace picker is interactive; the trailing wait // absorbs the first-connection reset wave the budget must include. - await page.getByRole('button', { name: 'Choose workspace' }).waitFor({ timeout: 30_000 }) + await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: 30_000 }) await page.waitForTimeout(3000) const describeCount = calls.filter(method => method === 'settings/describe').length expect(describeCount, `startup /api calls:\n${calls.join('\n')}`).toBe(DESCRIBE_BUDGET) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 082f5019ae..fc29e7f913 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -105,16 +105,25 @@ export function probeFreePort(): Promise { } /** - * Select a staged directory through the hero's composed Workspace picker. - * The default name keeps the Session cwd at /workspace for scenarios - * that require a specific directory instead of automatic first-use preparation. + * Drive the hero's workspace picker through the composed directory dialog + * until the live composer unlocks. A fresh world has no Workspace, so the boot + * lands in the Workspace-trigger view state (startup auto-selection has nothing to + * select); every scenario that types into the composer must connect one + * first. With nothing to list, activating the composer surface raises the dialog directly — + * adding a workspace is the picker's only entry. The directory is staged here + * and adopted through the path editor, which is idempotent across the repeated + * connects a scenario may make; creating a folder from inside the dialog (the + * product's other half of the same route) is covered by + * workspace-management.e2e.ts. The default name 'workspace' keeps the session + * header cwd at /workspace, the materialization proof several scenarios + * assert. * @param page - the page under test. * @param root - host directory the workspace folder is staged in (the scaffold's `workspaceCwd`). * @param name - folder name staged and adopted as the workspace. */ export async function connectFreshWorkspace(page: Page, root: string, name = 'workspace'): Promise { mkdirSync(join(root, name), { recursive: true }) - await page.getByRole('button', { name: 'Choose workspace', exact: true }).click() + await page.getByRole('textbox', { name: 'Choose workspace' }).click() const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'Edit path' }).click() @@ -122,7 +131,8 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo await pathInput.fill(join(root, name)) await pathInput.press('Enter') await dialog.getByRole('button', { name: 'Open', exact: true }).click() - await page.locator('[data-slot="conversation.session.header"]').waitFor({ state: 'attached', timeout: 15_000 }) + // The pick connected the workspace: the blank session's live composer + // replaces the locked placeholder and enables. await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]') .waitFor({ timeout: 15_000 }) } @@ -138,7 +148,7 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo */ export async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise { mkdirSync(join(root, name), { recursive: true }) - await page.getByRole('button', { name: '选择工作区', exact: true }).click() + await page.getByRole('textbox', { name: '选择工作区' }).click() const dialog = page.getByRole('dialog', { name: '选择工作区目录' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: '编辑路径' }).click() @@ -146,7 +156,6 @@ export async function connectFreshWorkspaceZh(page: Page, root: string, name = ' await pathInput.fill(join(root, name)) await pathInput.press('Enter') await dialog.getByRole('button', { name: '打开', exact: true }).click() - await page.locator('[data-slot="conversation.session.header"]').waitFor({ state: 'attached', timeout: 15_000 }) await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="描述你想要构建的内容, / 调用指令, @ 文件或对话"]') .waitFor({ timeout: 15_000 }) } From 2cd03c97582f84924ceafab9b0c8809d7606979d Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sun, 20 Sep 2026 17:20:54 +0800 Subject: [PATCH 10/60] refactor(workspace): omit unchanged registry state fields --- packages/workspace/workspace/src/index.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index d0f3d3b8a5..e2f67d948a 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -459,8 +459,6 @@ export class WorkspaceRegistry extends Service { initialized: true, ...(firstUse ? { defaultWorkspaceId: id } : {}), workspaceIds: [id, ...state.workspaceIds], - archivedSessionIds: state.archivedSessionIds, - pinnedSessionIds: state.pinnedSessionIds, }) } catch (error) { this.entities.delete(id) @@ -494,8 +492,6 @@ export class WorkspaceRegistry extends Service { pendingMutation: undefined, initialized: true, workspaceIds: state.workspaceIds.filter(workspaceId => workspaceId !== id), - archivedSessionIds: state.archivedSessionIds, - pinnedSessionIds: state.pinnedSessionIds, } await this.setState({ ...nextState, @@ -549,14 +545,7 @@ export class WorkspaceRegistry extends Service { ) } await this.requireTable().delete(pending.workspaceId) - await this.setState({ - ...state, - pendingMutation: undefined, - initialized: state.initialized, - workspaceIds: state.workspaceIds, - archivedSessionIds: state.archivedSessionIds, - pinnedSessionIds: state.pinnedSessionIds, - }) + await this.setState({ ...state, pendingMutation: undefined }) } private async bootstrap(headers: readonly SessionHeader[]): Promise { From 1628c1d030ab3eb045247aac6bb0358199ff0c94 Mon Sep 17 00:00:00 2001 From: winewill Date: Sun, 20 Sep 2026 17:23:08 +0800 Subject: [PATCH 11/60] feat(client): display current version in general settings --- .../settings-chrome/dialog-en.expected.md | 1 + .../settings-chrome/dialog.expected.md | 1 + apps/web/tests/settings-chrome.e2e.ts | 9 ++++-- .../ui-settings-general/README.i18n.yaml | 4 +-- packages/client/ui-settings-general/README.md | 2 ++ .../client/ui-settings-general/README.zh.md | 2 ++ .../src/client/CurrentVersionRow.module.css | 7 +++++ .../src/client/CurrentVersionRow.tsx | 14 ++++++++++ .../ui-settings-general/src/client/index.ts | 4 +++ .../ui-settings-general/src/client/locales.ts | 2 ++ .../tests/apply.client.spec.ts | 4 +-- .../tests/components.client.spec.tsx | 28 +++++++++++++++++-- .../src/client/slot-catalog.ts | 1 + 13 files changed, 71 insertions(+), 8 deletions(-) create mode 100644 packages/client/ui-settings-general/src/client/CurrentVersionRow.module.css create mode 100644 packages/client/ui-settings-general/src/client/CurrentVersionRow.tsx diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index 41d6462e11..1337d351d5 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -26,3 +26,4 @@ - switch "Developer tools" - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior - button "Queue" + - text: "Current version: {{version}}" diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index 9a3776bcb7..74db4241cd 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -26,3 +26,4 @@ - switch "开发者工具" - text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为 - button "排队发送" + - text: 当前版本:{{version}} diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index d84614d33c..02a67ef718 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -29,6 +29,8 @@ const PLUGIN_INSTANCES_EXPECTED = join(SNAPSHOT_DIR, 'plugin-instances.expected. const DIALOG_EN_EXPECTED = join(SNAPSHOT_DIR, 'dialog-en.expected.md') const PLUGIN_ROW_SELECTOR = '[data-plugin-scope="preset"] [data-plugin-entry="tool-subagent"]' const MODE = webSnapshotMode() +const { version } = JSON.parse(await readFile(new URL('../../../package.json', import.meta.url), 'utf8')) as { version: string } +const versionCapture = { replacements: [[version, '{{version}}']] as const } describe('web e2e: settings modal and General preferences', () => { let scaffold: WebScaffold @@ -93,8 +95,9 @@ describe('web e2e: settings modal and General preferences', () => { await expect.poll(() => openRequests, { timeout: 5_000 }).toBe(1) await expect.poll(() => openDocument.isEnabled(), { timeout: 5_000 }).toBe(true) await page.unroute('**/api/settings/openSettingsDocument') + await dialog.getByText(`当前版本:${version}`, { exact: true }).waitFor() // Golden of the freshly opened dialog (default zh, General active). - const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd, versionCapture) await compareOrRefreshGolden(DIALOG_EXPECTED, snapshot, MODE) // Section switch: aria-current moves (the Models page itself has its own scenario file). await dialog.getByRole('button', { name: '模型' }).click() @@ -603,6 +606,7 @@ describe('web e2e: settings modal and General preferences', () => { // ...and the attribute follows that switch, in the assembled app. await expect.poll(() => page.evaluate(() => document.documentElement.lang), { timeout: 5_000 }).toBe('en') expect(await enDialog.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true') + await enDialog.getByText(`Current version: ${version}`, { exact: true }).waitFor() await expect.poll(() => enDialog.getByText('Appearance', { exact: true }).count(), { timeout: 5_000 }).toBe(1) expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull() await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) @@ -712,7 +716,8 @@ describe('web e2e: settings modal and General preferences', () => { // Golden of the English fallback dialog — the visible output this change // produces. The zh golden above covers the detected-locale surface, so // the pair pins both directions of the resolution. - const snapshot = await captureStableAria(frPage, '[role="dialog"]', fresh.workspaceCwd) + await dialog.getByText(`Current version: ${version}`, { exact: true }).waitFor() + const snapshot = await captureStableAria(frPage, '[role="dialog"]', fresh.workspaceCwd, versionCapture) await compareOrRefreshGolden(DIALOG_EN_EXPECTED, snapshot, MODE) expect(frTripwire.pageErrors).toEqual([]) expect(frTripwire.warnings).toEqual([]) diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 2f86bc5b7c..9d9fbdc3e0 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/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-settings-general/README.md -README.md: ebdb22adc071b5df435fddd6620dd982d866aa36 -README.zh.md: 0bb188ea5024b14e68baa1a687a654ee8963f3d7 +README.md: 460a066a9309b698d8d3b54e6f1e2c752c12545b +README.zh.md: f81ca419a9caad6a0d5833b7e21bc42030bf2234 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index ebdb22adc0..460a066a93 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -31,6 +31,8 @@ In Desktop, the account-row update control shows availability, progress, verific ### The General section +The current release version appears at the bottom of General Settings in Web and Desktop, using the build’s `DSH_CLIENT_VERSION` metadata and the active language. Partial builds without version metadata omit the row. + The Developer tools switch controls the shared preference described by [ui-settings](../ui-settings/README.md#use-this-package). It is available in both Web and desktop, follows accepted changes immediately, and disables duplicate input while a write settles. A failed write displays localized retry guidance. The General section holds the built-in Developer tools row and rows registered into `settings.general.item` by feature packages. Each registrant owns its row copy and behavior. The Appearance row, for example, lives in ui-theme. diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 0bb188ea50..f81ca419a9 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -31,6 +31,8 @@ kind: "package-reference" ### 「通用」分区 +Web 与桌面端的通用设置底部显示当前发布版本,使用构建注入的 `DSH_CLIENT_VERSION` 元数据和当前语言。缺少版本元数据的局部构建不显示该行。 + 开发者工具开关控制 [ui-settings](../ui-settings/README.zh.md#use-this-package) 定义的共享偏好。Web 和桌面端均提供此开关,立即跟随已接受的变更,并在写入完成前禁用重复输入。写入失败时显示本地化的重试提示。 「通用」分区承载内置的开发者工具行和功能包注册进 `settings.general.item` 的行。每个注册方拥有自己的行文案与行为。例如「外观」行位于 ui-theme。 diff --git a/packages/client/ui-settings-general/src/client/CurrentVersionRow.module.css b/packages/client/ui-settings-general/src/client/CurrentVersionRow.module.css new file mode 100644 index 0000000000..66f159a3dd --- /dev/null +++ b/packages/client/ui-settings-general/src/client/CurrentVersionRow.module.css @@ -0,0 +1,7 @@ +.row { + padding: 16px 0; + color: var(--dsw-alias-label-primary); + font-size: 14px; + line-height: 22px; + overflow-wrap: anywhere; +} diff --git a/packages/client/ui-settings-general/src/client/CurrentVersionRow.tsx b/packages/client/ui-settings-general/src/client/CurrentVersionRow.tsx new file mode 100644 index 0000000000..d02213a8d8 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/CurrentVersionRow.tsx @@ -0,0 +1,14 @@ +/** Installed release version in General Settings for Web and Desktop. */ +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import css from './CurrentVersionRow.module.css' + +/** + * Render the version embedded by the client build; partial builds without metadata omit the row. + * @param props - Localized settings copy. + * @returns the current release label, or nothing when build metadata is absent. + */ +export function CurrentVersionRow({ t }: PropsRuntime<'settings.general.item'> & PropsLocale<'settings'>) { + const version = process.env.DSH_CLIENT_VERSION + if (version === undefined) return null + return
{t('general.currentVersion', { version })}
+} diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 442ebc0c14..abd320ac69 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -29,6 +29,7 @@ import type { DesktopUpdateBridge } from './desktop-update-bridge.ts' import { DesktopUpdateSource } from './desktop-update-source.ts' import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx' import { GeneralSection } from './GeneralSection.tsx' +import { CurrentVersionRow } from './CurrentVersionRow.tsx' import { DeveloperToolsRow, type DeveloperToolsRowInjected } from './DeveloperToolsRow.tsx' import { SettingsDocumentAction } from './SettingsDocumentAction.tsx' import type { SettingsDocumentActionInjected } from './SettingsDocumentAction.tsx' @@ -76,6 +77,9 @@ export function apply(ctx: ClientContext): void { setEnabled: enabled => ctx.settingsScope.developerTools.setEnabled(enabled), }), }, DeveloperToolsRow)) + ctx.slots.inject('settings.general.item', () => ctx.slots.register({ + name: 'settings.general.item', id: 'current-version', order: 100, locale: NS, + }, CurrentVersionRow)) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-general: dictionaries') const connection = ctx.get('connection') as ConnectionHandle const carrier = (globalThis as typeof globalThis & { dshDesktop?: { protocolVersion: number; updates?: DesktopUpdateBridge } }).dshDesktop diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index e906f1714c..9608a28152 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -26,6 +26,7 @@ export const zh = { 'openDocument': '打开配置文件', 'openDocument.error': '无法打开配置文件', 'general.nav': '通用设置', + 'general.currentVersion': '当前版本:{version}', 'developerTools.title': '开发者工具', 'developerTools.error': '保存失败,请重试', 'developerTools.description': '显示用于调试和排查问题的工具与信息', @@ -65,6 +66,7 @@ export const en = { 'openDocument': 'Open configuration file', 'openDocument.error': 'Could not open configuration file', 'general.nav': 'General', + 'general.currentVersion': 'Current version: {version}', 'developerTools.title': 'Developer tools', 'developerTools.error': 'Could not save. Please try again.', 'developerTools.description': 'Show tools and information for debugging and troubleshooting', diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index 1d11e8ea30..14bbb0bc62 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -102,7 +102,7 @@ describe('ui-settings-general apply', () => { expect(generalLabel(c)).toBe('通用设置') expect(c.ctx.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) // The shared developer-tool control belongs to General; onboarding remains feature-owned. - expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS).map(row => row.options.id)).toEqual(['developer-tools']) + expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS).map(row => row.options.id)).toEqual(['developer-tools', 'current-version']) expect(c.ctx.slots.entries('settings.onboarding').filter(row => row.locale === NS)).toEqual([]) const developerRow = c.ctx.slots.entries('settings.general.item').find(row => row.options.id === 'developer-tools')! const developer = (developerRow.inject as unknown as () => DeveloperToolsRowInjected)() @@ -213,7 +213,7 @@ describe('ui-settings-general apply', () => { expect(ownEntries(c, name)[0]).not.toBe(before[index]) }) expect(c.ctx.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) - expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS).map(row => row.options.id)).toEqual(['developer-tools']) + expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS).map(row => row.options.id)).toEqual(['developer-tools', 'current-version']) // The recovered registrations still ride the locale path. const english = localeView('en', 1) const chinese = localeView('zh', 2) diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index 89c78fe1af..e73047e0de 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -22,11 +22,12 @@ function derivedDocumentStore(remote: object) { const ctx = { remote } as never return new SettingsDocumentStore(ctx, new SettingsDescribeMirror(ctx)) } -import { en } from '../src/client/locales.ts' +import { en, zh } from '../src/client/locales.ts' +import { CurrentVersionRow } from '../src/client/CurrentVersionRow.tsx' import { DesktopUpdateBadge } from '../src/client/DesktopUpdateIndicator.tsx' import type { DesktopUpdateView } from '../src/client/desktop-update-bridge.ts' -afterEach(cleanup) +afterEach(() => { cleanup(); vi.unstubAllEnvs() }) // The seat's key domain is settings ∪ common; the stub answers from the // package dictionary and falls back to the key like the real chain. @@ -222,3 +223,26 @@ it('reports a failed developer-tool write and allows retry', async () => { await waitFor(() => { expect(toggle.getAttribute('aria-checked')).toBe('true') }) expect(screen.queryByRole('alert')).toBeNull() }) + +describe('current version', () => { + it.each([ + [en, 'Current version: 1.2.3-rc.4'], + [zh, '当前版本:1.2.3-rc.4'], + ])('renders the localized release label %s', (dictionary, expected) => { + vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3-rc.4') + const translate: TriggerContentProps['t'] = (key, params) => { + let text = (dictionary as Record)[key] ?? key + for (const [name, value] of Object.entries(params ?? {})) text = text.replace(`{${name}}`, String(value)) + return text + } + render() + expect(screen.getByText(expected)).toBeTruthy() + expect(screen.queryByRole('button')).toBeNull() + }) + + it('omits the row when a partial build has no version metadata', () => { + vi.stubEnv('DSH_CLIENT_VERSION', undefined) + const view = render() + expect(view.container.textContent).toBe('') + }) +}) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 76a69a3cdf..d210bfe37f 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1993,6 +1993,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-conversation EnterBehaviorRow id \'composer-enter\'', 'client-ui-permission-presets PermissionRow id \'permission\'', 'client-ui-settings-general DeveloperToolsRow id \'developer-tools\'', + 'client-ui-settings-general CurrentVersionRow id \'current-version\'', 'client-ui-theme AppearanceRow id \'appearance\'', 'client-ui-theme FontSizeRow id \'font-size\'', ], From c1f37fef9d8ac535bb983222e6eec8abb3d1e11d Mon Sep 17 00:00:00 2001 From: Yudong Date: Sun, 20 Sep 2026 17:39:28 +0800 Subject: [PATCH 12/60] fix(sidebar): open spreadsheets in default app --- .../2026-09-11-node-office-kit.i18n.yaml | 4 +- .../2026-09-11-node-office-kit.md | 2 +- .../2026-09-11-node-office-kit.zh.md | 2 +- apps/web/tests/document-preview.e2e.ts | 39 +++++++++++++------ .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 6 +-- .../ui-sidebar-documentpreview/README.zh.md | 6 +-- .../src/client/office/index.ts | 2 +- .../tests/office-registration.client.spec.ts | 7 ++-- .../web/document-preview/document.expected.md | 7 +++- 10 files changed, 51 insertions(+), 28 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml index c27ef04676..9947e91ff2 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.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 .agents/notes/implemented/architecture/2026-09-11-node-office-kit.md -2026-09-11-node-office-kit.md: 6cab947d7cb8d4c2c416a83f0b765813c7f98ea9 -2026-09-11-node-office-kit.zh.md: 8ed1a81922249cf0b9c1a74b06e5ce9d44696e0a +2026-09-11-node-office-kit.md: b8e94bf6b392edf7931346400fda9ca5d3416188 +2026-09-11-node-office-kit.zh.md: 41b84f26c8150db90f09e4ae2b01abb3dddfdc6d diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md index 6cab947d7c..b8e94bf6b3 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md @@ -14,7 +14,7 @@ The [office-to-pdf capability](../../../../packages/document/README.md) delegate The [platform engine decision](2026-09-15-platform-office-engines.md) requires the kit’s declared native target engine, or WASM when no native target is declared. Missing or invalid required engines reject conversion. The shared [bounded provider](2026-09-15-bounded-office-conversion.md) owns admission, conversion reuse, and cancellation through scratch cleanup. Preview consumes that provider without registering another converter or requiring Office authoring skills. -The service, Remote methods, and Client registration accept DOC, DOCX, XLS, XLSX, PPT, and PPTX. The kit verifies bounded ZIP membership and content types for OOXML inputs and the OLE compound-file header for binary Office inputs before LibreOffice imports them. Renaming text to an Office suffix does not admit it. Binary formats return no missing-font diagnostics because the kit does not extract their font tables. It writes a fresh, exclusively created PDF in a caller-owned private directory. DSH reads and validates the complete output before removing scratch files. The [service’s Remote method](../../../../packages/document/office-to-pdf/README.md) authorizes source access through [Workspace Files](2026-09-09-workspace-file-read-authority.md), preserves the source path/version, and returns PDF bytes. Source read caps and generated PDF caps remain independent. One source path/version snapshot governs the access probe and deferred read, including size-failure rechecks, so conversion cannot publish bytes under another identity. Preview bytes do not enter Session storage or persistent caches. +The service and Remote methods accept DOC, DOCX, XLS, XLSX, PPT, and PPTX. The Client preview registers DOC, DOCX, PPT, and PPTX; XLS and XLSX use the unsupported-preview state and can open in the system default application without requesting conversion. The kit verifies bounded ZIP membership and content types for OOXML inputs and the OLE compound-file header for binary Office inputs before LibreOffice imports them. Renaming text to an Office suffix does not admit it. Binary formats return no missing-font diagnostics because the kit does not extract their font tables. It writes a fresh, exclusively created PDF in a caller-owned private directory. DSH reads and validates the complete output before removing scratch files. The [service’s Remote method](../../../../packages/document/office-to-pdf/README.md) authorizes source access through [Workspace Files](2026-09-09-workspace-file-read-authority.md), preserves the source path/version, and returns PDF bytes. Source read caps and generated PDF caps remain independent. One source path/version snapshot governs the access probe and deferred read, including size-failure rechecks, so conversion cannot publish bytes under another identity. Preview bytes do not enter Session storage or persistent caches. A converter reuses font metadata obtained by its first conversion Worker; original font buffers and decoded glyph coverage remain conversion-local. Workers validate indexed files when reading them. Exact installed families precede configured alternatives, and complete family/style/weight/italic/width/pitch/language/code-point requests retain their distinct matches. WASM callbacks import original font files, including complete collections, into MEMFS. Native engines also retain their platform's font discovery. Neither path downloads or installs fonts; native OS-managed font memory is outside the explicit import budget. Recreating the converter refreshes its metadata after font changes. diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md index 8ed1a81922..41b84f26c8 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md @@ -14,7 +14,7 @@ Status: implemented [平台引擎决策](2026-09-15-platform-office-engines.zh.md)要求使用 kit 已声明的原生目标引擎,未声明原生目标时使用 WASM。缺失或无效的必需引擎会拒绝转换。共享的[有界提供方](2026-09-15-bounded-office-conversion.zh.md)负责准入、转换复用以及持续到临时文件清理完成的取消。预览消费该提供方,不注册另一个转换器,也不依赖 Office 创作 skills。 -服务、Remote 方法和 Client 注册接受 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。LibreOffice 导入前,kit 校验 OOXML 输入的有界 ZIP 成员和内容类型,以及二进制 Office 输入的 OLE 复合文件头。将文本改为 Office 后缀不能通过校验。kit 不提取二进制格式的字体表,因此这些格式不返回缺失字体诊断。kit 在调用方拥有的私有目录中独占创建新的 PDF。DSH 读取并校验完整输出后才删除临时文件。[服务的 Remote 方法](../../../../packages/document/office-to-pdf/README.zh.md)通过 [Workspace Files](2026-09-09-workspace-file-read-authority.zh.md)授权源文件访问,保留源路径和版本,并返回 PDF 字节。源文件读取上限与生成 PDF 上限相互独立。读取权限探测和延迟读取(包括超限失败后的复查)采用同一个源路径/版本快照,防止转换将字节发布到另一个源身份下。预览字节不会进入 Session 存储或持久缓存。 +服务与 Remote 方法接受 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。Client 预览注册 DOC、DOCX、PPT 和 PPTX;XLS 与 XLSX 使用不支持预览的空状态,可由系统默认应用打开,并且不请求转换。LibreOffice 导入前,kit 校验 OOXML 输入的有界 ZIP 成员和内容类型,以及二进制 Office 输入的 OLE 复合文件头。将文本改为 Office 后缀不能通过校验。kit 不提取二进制格式的字体表,因此这些格式不返回缺失字体诊断。kit 在调用方拥有的私有目录中独占创建新的 PDF。DSH 读取并校验完整输出后才删除临时文件。[服务的 Remote 方法](../../../../packages/document/office-to-pdf/README.zh.md)通过 [Workspace Files](2026-09-09-workspace-file-read-authority.zh.md)授权源文件访问,保留源路径和版本,并返回 PDF 字节。源文件读取上限与生成 PDF 上限相互独立。读取权限探测和延迟读取(包括超限失败后的复查)采用同一个源路径/版本快照,防止转换将字节发布到另一个源身份下。预览字节不会进入 Session 存储或持久缓存。 converter 复用首个转换 Worker 返回的字体元数据;原始字体缓冲区和解码后的字符覆盖范围仍只属于单次转换。Worker 读取字体时校验索引中的文件。已安装字体族的精确匹配优先于配置的替代字体,完整的字体族、样式、字重、斜体、宽度、字距、语言与码点请求保留各自的匹配结果。WASM 回调将包含完整字体集合的原始字体文件导入 MEMFS。原生引擎还保留各平台的字体发现能力。两条路径均不下载或安装字体;原生操作系统管理的字体内存不受显式导入预算约束。字体变化后,重新创建 converter 会刷新元数据。 diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 8c41184009..d78cf7b464 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -260,7 +260,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + const restoredAdd = await addTab.count() expect(restoredFilesClose).toBe(1) expect(restoredAdd).toBe(1) - const preview = column.locator('[data-document-preview]') + const preview = column.locator('[data-textpreview-url]') const openFile = openPreviewFile.bind(undefined, column, filesTab, preview) const viewer = preview.locator('[data-document-viewer-menu]') const body = preview.locator('[data-textpreview-body]') @@ -696,7 +696,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + const officeMenus: number[] = [] const configurationGuide = 'Read failed: Office previews are unavailable. Enable the document preview service on the computer running DeepSeek Harness.' - for (const extension of ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']) { + for (const extension of ['doc', 'docx', 'ppt', 'pptx']) { await openFile(`unavailable.${extension}`) expect(await preview.locator('[data-document-viewer-menu]').count()).toBe(0) await preview.getByText(configurationGuide, { exact: true }).waitFor({ timeout: 15_000 }) @@ -707,12 +707,23 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + await successShot(page, 'office-unavailable') sections.push([ '## Office unavailable', '', - `- DOC, DOCX, XLS, XLSX, PPT, PPTX viewer menus: ${officeMenus.join(' | ')}`, + `- DOC, DOCX, PPT, PPTX viewer menus: ${officeMenus.join(' | ')}`, `- Guidance: ${configurationGuide}`, '- Binary text shown: false', '- Plain-text option and viewer picker: hidden', ].join('\n')) + const spreadsheetStates: string[] = [] + for (const extension of ['xls', 'xlsx']) { + await openFile(`unavailable.${extension}`) + const spreadsheet = column.locator('[data-textpreview-state="unsupported"]') + await spreadsheet.waitFor({ timeout: 15_000 }) + expect(await spreadsheet.getByText('Preview is not available for this file type yet.', { exact: true }).count()).toBe(1) + await spreadsheet.locator('[data-open-path-unpreviewable]').waitFor({ timeout: 15_000 }) + spreadsheetStates.push(`${extension.toUpperCase()}: unsupported / Open in default app`) + } + sections.push(['## Spreadsheet preview', '', `- ${spreadsheetStates.join('\n- ')}`].join('\n')) + await openFile('notes.unknown') const plainLines = preview.locator('[data-textpreview-line]') await expect.poll(() => plainLines.count()).toBe(2) @@ -810,7 +821,7 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' }) await column.locator('[data-files-reload]').click() const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) }) - const preview = column.locator('[data-document-preview]') + const preview = column.locator('[data-textpreview-url]') await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'chinese.docx', exact: true }).click() expect(await preview.locator('[data-document-viewer-menu]').count()).toBe(0) const canvas = preview.getByRole('img', { name: 'PDF page 1', exact: true }) @@ -885,30 +896,36 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { `- Document top inset: ${topInset}px`, ].join('\n'), MODE) await successShot(page, 'office-docx') - for (const extension of ['doc', 'xls', 'xlsx', 'ppt', 'pptx']) { + for (const extension of ['doc', 'ppt', 'pptx']) { await openPreviewFile(column, filesTab, preview, `chinese.${extension}`) await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible', timeout: 60_000 }) await expect.poll(async () => (await preview.locator('[data-pdf-text]').allTextContents()).join(''), { timeout: 30_000 }).toContain('中文文档') - if (['doc', 'xls', 'ppt'].includes(extension)) expect(await warning.count()).toBe(0) + if (['doc', 'ppt'].includes(extension)) expect(await warning.count()).toBe(0) await successShot(page, `office-${extension}`) } - expect(convert).toHaveBeenCalledTimes(6) + expect(convert).toHaveBeenCalledTimes(4) + for (const extension of ['xls', 'xlsx']) { + await openPreviewFile(column, filesTab, preview, `chinese.${extension}`) + await preview.getByText('Preview is not available for this file type yet.', { exact: true }).waitFor({ timeout: 15_000 }) + expect(await preview.getByRole('img', { name: 'PDF page 1', exact: true }).count()).toBe(0) + } + expect(convert).toHaveBeenCalledTimes(4) await openPreviewFile(column, filesTab, preview, 'chinese.docx') await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible' }) await preview.getByRole('button', { name: 'Read the file again', exact: true }).click() - await expect.poll(() => convert.mock.calls.length).toBe(7) + await expect.poll(() => convert.mock.calls.length).toBe(5) await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible' }) await openPreviewFile(column, filesTab, preview, 'renamed.docx') await preview.getByText('Read failed: This Office file cannot be previewed. It may be damaged, password protected, or have the wrong extension.', { exact: true }).waitFor({ timeout: 30_000 }) expect(await preview.locator('[data-textpreview-line]').count()).toBe(0) await successShot(page, 'office-invalid') - expect(convert).toHaveBeenCalledTimes(8) - for (const extension of ['doc', 'xls', 'ppt']) { + expect(convert).toHaveBeenCalledTimes(6) + for (const extension of ['doc', 'ppt']) { await openPreviewFile(column, filesTab, preview, `renamed.${extension}`) await preview.getByText('Read failed: This Office file cannot be previewed. It may be damaged, password protected, or have the wrong extension.', { exact: true }).waitFor({ timeout: 30_000 }) expect(await preview.locator('[data-textpreview-line]').count()).toBe(0) } - expect(convert).toHaveBeenCalledTimes(11) + expect(convert).toHaveBeenCalledTimes(8) expect(tripwire.pageErrors).toEqual([]) } finally { convert.mockRestore() } }) diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 6f0e375dcb..141c7e0474 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: e7360434961ce480d2e97cb2ef41685b8cd8e14f -README.zh.md: 7a60e3a94b931ed50e37214a9c2c63ed5ce6cbbb +README.md: 2797d6fd6be8f429043c8de1413f90e64bda3876 +README.zh.md: a04571ed6d76ca812aa0a70ce6f3c403ad80947d diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index e736043496..2797d6fd6b 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Preview files in the right Sidebar and choose among registered renderers. Markdown and code support paged text; PDF, HTML, and common images receive complete bytes; unknown extensions use plain text. Office documents convert locally to PDF. The tab provides file status, renderer selection, wrap, and automatic or manual reload. Plugins can add local opening controls to the header and unsupported-preview empty state. +Preview files in the right Sidebar and choose among registered renderers. Markdown and code support paged text; PDF, HTML, and common images receive complete bytes; unknown extensions use plain text. Word and PowerPoint documents convert locally to PDF; Excel workbooks use the unsupported-preview state. The tab provides file status, renderer selection, wrap, and automatic or manual reload. Plugins can add local opening controls to the header and unsupported-preview empty state. ## Table of Contents @@ -65,7 +65,7 @@ Initial reads, additional pages, and HTML/PDF/image preparation share the ongoin ## Office preview -Open `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, and `.pptx` as PDF previews with the same loading state, controls, cancellation, and selectable text as PDF files. The [Host provider](../../document/office-to-pdf/README.md) performs local conversion; invalid files, conversion failures, and timeouts receive localized messages. Missing Host services show configuration guidance. +Open `.doc`, `.docx`, `.ppt`, and `.pptx` as PDF previews with the same loading state, controls, cancellation, and selectable text as PDF files. `.xls` and `.xlsx` do not register a Sidebar renderer: they show the unsupported-preview message without requesting conversion and offer the system default application when the Host reports a desktop. The [Host provider](../../document/office-to-pdf/README.md) retains its spreadsheet conversion API for other consumers. Invalid supported files, conversion failures, and timeouts receive localized messages. Missing Host services show configuration guidance. The [Web bundle](../../bundle/web-app/README.md) mounts this package as `ui-sidebar-documentpreview`. Configure its transient Office cache through that entry's `office` settings; the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-client-ui-sidebar-documentpreview) defines accepted values. Settings are embedded in each served page; reload the browser page after changing YAML. @@ -108,7 +108,7 @@ No direct effect; what the user reads here never enters a model request. - **Preview, not editing.** The viewers provide no file editing or shared search interface; a directory address fails with `not-regular-file`. Unknown extensions use the plain-text reader and remain subject to its UTF-8/NUL checks. -- **Office conversion limits.** The preview does not launch native Office editors or download an engine. Binary `.doc`, `.xls`, and `.ppt` files return no missing-font diagnostics. Conversion fidelity and resource limits belong to the [LibreOffice provider](../../document/office-to-pdf/README.md). +- **Office conversion limits.** The preview does not launch native Office editors or download an engine. Excel workbooks are not previewed and instead offer the system default application on desktop Hosts. Binary `.doc` and `.ppt` files return no missing-font diagnostics. Conversion fidelity and resource limits belong to the [LibreOffice provider](../../document/office-to-pdf/README.md). - **Sequential text and bounded complete files.** Deep source lines require the preceding pages; PDF, HTML, and images require a complete result within the Host's `maxFileBytes` cap. - **Byte-view scroll state is not restored.** PDF, HTML, and images can return to the top when their renderer remounts or reloads; images fit the pane's width and never scroll horizontally, and HTML iframe scrolling belongs to its opaque browsing context. - **Finite local HTML dependencies.** Only direct classic `.js` and stylesheet `.css` references are packed. Browser-resolved resources retain browser origin and network restrictions; no runtime file-read bridge is exposed to the iframe. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index 7a60e3a94b..a04571ed6d 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在右侧 Sidebar 预览文件,并切换已注册的渲染器。Markdown 和代码支持分页文本;PDF、HTML 和常见图片接收完整字节;未知扩展名使用纯文本。Office 文档在本地转换为 PDF。tab 提供文件状态、渲染器选择、换行以及自动或手动重新载入。插件可在头部和不支持预览的空态中添加本地打开控件。 +在右侧 Sidebar 预览文件,并切换已注册的渲染器。Markdown 和代码支持分页文本;PDF、HTML 和常见图片接收完整字节;未知扩展名使用纯文本。Word 与 PowerPoint 文档在本地转换为 PDF;Excel 工作簿使用不支持预览的空态。tab 提供文件状态、渲染器选择、换行以及自动或手动重新载入。插件可在头部和不支持预览的空态中添加本地打开控件。 ## 目录 @@ -65,7 +65,7 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态 ## Office 预览 -将 `.doc`、`.docx`、`.xls`、`.xlsx`、`.ppt` 和 `.pptx` 打开为 PDF 预览,使用与 PDF 文件相同的加载状态、控件、取消和文本选择能力。[Host 提供方](../../document/office-to-pdf/README.zh.md)负责本地转换;无效文件、转换失败和超时会显示本地化消息。缺少 Host 服务时显示配置引导。 +将 `.doc`、`.docx`、`.ppt` 和 `.pptx` 打开为 PDF 预览,使用与 PDF 文件相同的加载状态、控件、取消和文本选择能力。`.xls` 与 `.xlsx` 不注册 Sidebar 渲染器:它们不请求转换,直接显示不支持预览的提示;Host 报告存在桌面时,还提供用系统默认应用打开的控件。[Host 提供方](../../document/office-to-pdf/README.zh.md)仍为其他消费者保留电子表格转换 API。受支持的无效文件、转换失败和超时会显示本地化消息。缺少 Host 服务时显示配置引导。 [Web bundle](../../bundle/web-app/README.zh.md) 以 `ui-sidebar-documentpreview` 挂载本包。通过该条目的 `office` 设置配置临时 Office 缓存;[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-client-ui-sidebar-documentpreview)定义可接受的值。设置注入到每个页面;修改 YAML 后重新加载浏览器页面。 @@ -108,7 +108,7 @@ Office 注册、加载、缓存和字体提示位于 `src/client/office/`。Offi - **预览而非编辑。** 查看器不提供文件编辑或共享搜索接口;目录地址以 `not-regular-file` 失败。未知扩展名使用纯文本读取,仍受其 UTF-8/NUL 检查限制。 -- **Office 转换限制。** 预览不启动原生 Office 编辑器,也不下载引擎。二进制 `.doc`、`.xls` 和 `.ppt` 文件不返回缺失字体诊断。转换保真度与资源限制由 [LibreOffice 提供方](../../document/office-to-pdf/README.zh.md)负责。 +- **Office 转换限制。** 预览不启动原生 Office 编辑器,也不下载引擎。Excel 工作簿不提供预览,在桌面 Host 上改为提供用系统默认应用打开的控件。二进制 `.doc` 和 `.ppt` 文件不返回缺失字体诊断。转换保真度与资源限制由 [LibreOffice 提供方](../../document/office-to-pdf/README.zh.md)负责。 - **文本顺序分页,完整文件受限。** 定位到较深处的源码行需要先加载此前各页;PDF、HTML 和图片必须取得 Host `maxFileBytes` 上限内的完整结果。 - **字节视图不恢复滚动位置。** PDF、HTML 与图片的渲染器重新挂载或重新载入时可能回到顶部;图片适配面板宽度、不产生横向滚动,HTML iframe 的滚动属于其不透明浏览上下文。 - **本地 HTML 依赖集合有限。** 只打包直接引用的经典 `.js` 脚本和 `.css` 样式表。浏览器解析的资源仍受浏览器源与网络规则限制;iframe 不获得运行时文件读取桥接。 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts b/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts index 66b24e0db1..0672b5f1ed 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts @@ -31,7 +31,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { */ export function apply(ctx: Context, config: Config['office']): void { const id = '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office' - const extensions = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'] + const extensions = ['doc', 'docx', 'ppt', 'pptx'] ctx.effect(() => ctx.locale.register('sidebarOffice', { zh, en })) const t = ctx.locale.bind('sidebarOffice') const unavailable: ReadOfficeDocument = (_file, signal) => { diff --git a/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts index 22bd4da75b..98a980f49d 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts @@ -81,17 +81,18 @@ async function harness(config: Partial = {}, missing?: 'remote } } -it.each(['remote', 'render', 'files'] as const)('keeps Office registration and guidance when %s is absent', async (missing) => { +it.each(['remote', 'render', 'files'] as const)('keeps Word and PowerPoint registration and guidance when %s is absent', async (missing) => { const h = await harness(undefined, missing) try { expect(h.locale.register).toHaveBeenCalledWith('sidebarOffice', { zh, en }) - for (const path of ['a.DOC', 'b.DOCX', 'c.XLS', 'd.xlsx', 'e.PPT', 'f.pptx']) { - expect(h.registry.candidates(path)[0]!.binaryExtensions).toEqual(['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']) + for (const path of ['a.DOC', 'b.DOCX', 'c.PPT', 'd.pptx']) { + expect(h.registry.candidates(path)[0]!.binaryExtensions).toEqual(['doc', 'docx', 'ppt', 'pptx']) expect(h.registry.candidates(path)[0]!.title()).toBe(en.title) expect(h.registry.candidates(path)[0]!.loading).toBe('renderer') expect(h.registry.candidates(path)[0]).not.toHaveProperty('read') await expect(h.read(undefined, path)).rejects.toThrow(en.unavailable) } + for (const path of ['sheet.XLS', 'sheet.xlsx']) expect(h.registry.candidates(path)).toEqual([]) expect(h.render).not.toHaveBeenCalled() } finally { await h.close() } expect(h.registry.getSnapshot()).toEqual([]) diff --git a/snapshots/web/document-preview/document.expected.md b/snapshots/web/document-preview/document.expected.md index 5495962ea8..a787f8f52e 100644 --- a/snapshots/web/document-preview/document.expected.md +++ b/snapshots/web/document-preview/document.expected.md @@ -67,11 +67,16 @@ ## Office unavailable -- DOC, DOCX, XLS, XLSX, PPT, PPTX viewer menus: 0 | 0 | 0 | 0 | 0 | 0 +- DOC, DOCX, PPT, PPTX viewer menus: 0 | 0 | 0 | 0 - Guidance: Read failed: Office previews are unavailable. Enable the document preview service on the computer running DeepSeek Harness. - Binary text shown: false - Plain-text option and viewer picker: hidden +## Spreadsheet preview + +- XLS: unsupported / Open in default app +- XLSX: unsupported / Open in default app + ## Unknown suffix - Viewer menu hidden: true From 05a229ccc9a42e2be71565fd26d87b216dc8bd46 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 20 Sep 2026 02:45:48 -0700 Subject: [PATCH 13/60] feat(web): clarify agent presets and add mode guides --- apps/web/tests/agent-preset-authoring.e2e.ts | 68 ++++++-- apps/web/tests/agent-preset-selection.e2e.ts | 10 +- .../copy-dialog.expected.md | 4 +- .../created.expected.md | 59 ++++--- .../damaged.expected.md | 66 +++++--- .../section.expected.md | 52 +++--- .../agent-preset-selection/menu.expected.md | 8 +- .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 2 + packages/client/ui-agent-preset/README.zh.md | 2 + .../src/client/AgentPresetSection.module.css | 66 ++++++-- .../src/client/AgentPresetSection.tsx | 81 ++++++--- .../src/client/PresetGuideDialog.module.css | 146 ++++++++++++++++ .../src/client/PresetGuideDialog.tsx | 157 ++++++++++++++++++ .../src/client/guide-locales.ts | 152 +++++++++++++++++ .../ui-agent-preset/src/client/locales.ts | 87 +++++----- .../tests/section.client.spec.tsx | 106 +++++++++++- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 1 + packages/client/ui-primitives/README.zh.md | 1 + .../src/SegmentedTabs.module.css | 53 ++++++ .../ui-primitives/src/SegmentedTabs.tsx | 81 +++++++++ packages/client/ui-primitives/src/index.ts | 2 + .../tests/SegmentedTabs.client.spec.tsx | 62 +++++++ 24 files changed, 1090 insertions(+), 184 deletions(-) create mode 100644 packages/client/ui-agent-preset/src/client/PresetGuideDialog.module.css create mode 100644 packages/client/ui-agent-preset/src/client/PresetGuideDialog.tsx create mode 100644 packages/client/ui-agent-preset/src/client/guide-locales.ts create mode 100644 packages/client/ui-primitives/src/SegmentedTabs.module.css create mode 100644 packages/client/ui-primitives/src/SegmentedTabs.tsx create mode 100644 packages/client/ui-primitives/tests/SegmentedTabs.client.spec.tsx diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index 88ae171e85..2572b1a5ff 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -78,30 +78,68 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { await dialog.getByRole('heading', { name: 'Agent 预设' }).waitFor({ timeout: 10_000 }) // The intro copy also names 标准模式. Wait for the roster's own action so // the snapshot cannot land between the section shell and its cards. - await dialog.getByRole('button', { name: '查看: 标准模式', exact: true }).waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: '查看配置: 标准模式', exact: true }).waitFor({ timeout: 10_000 }) const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(SECTION_EXPECTED, snapshot, MODE) - const toggle = dialog.getByRole('switch', { name: '允许切换agent模式' }) + const toggle = dialog.getByRole('switch', { name: '新任务可选择模式' }) expect(await toggle.getAttribute('aria-checked')).toBe('true') - // The intro states the copy path directly, and the shipped rows offer + // The authoring entry is visible, and the shipped rows offer // view/copy but never delete or a location — their // install is overwritten by upgrades and is not the user's to manage. - expect(snapshot).toContain('或用「创造模式」让 Agent 帮你创建') + expect(snapshot).toContain('让 Agent 帮我创建预设模式') expect(snapshot).not.toContain('新建预设') - expect(snapshot).toContain('查看: 标准模式') + expect(snapshot).toContain('查看配置: 标准模式') expect(snapshot).not.toContain('删除: 标准模式') expect(snapshot).not.toContain('打开目录') // The rest of this scenario exercises the existing default and Creator // actions with the beta picker enabled by default. }, 60_000) + it('reads technical details and examples without changing the new-task default', async () => { + const settings = settingsDialog() + const trigger = settings.getByRole('button', { name: '模式说明: PTC 模式', exact: true }) + await trigger.click() + const guide = page.getByRole('dialog', { name: 'PTC 模式', exact: true }) + await guide.getByRole('heading', { name: '怎样调用工具', exact: true }).waitFor() + const beforeSwitch = await guide.boundingBox() + expect(await guide.textContent()).toContain('run_code') + await guide.getByRole('tab', { name: '如何使用', exact: true }).click() + await guide.getByRole('heading', { name: '批量检查配置文件', exact: true }).waitFor() + expect(await guide.boundingBox()).toEqual(beforeSwitch) + await guide.getByRole('tab', { name: '如何使用', exact: true }).press('ArrowLeft') + expect(await guide.getByRole('tab', { name: '模式说明', exact: true }).getAttribute('aria-selected')).toBe('true') + await guide.getByRole('tab', { name: '模式说明', exact: true }).press('Escape') + await guide.waitFor({ state: 'detached' }) + expect(await trigger.evaluate(element => document.activeElement === element)).toBe(true) + expect(await settings.isVisible()).toBe(true) + expect(await settings.getByRole('button', { name: '新任务默认: 标准模式', exact: true }).getAttribute('aria-pressed')).toBe('true') + + await settings.getByRole('button', { name: '如何使用: 创造模式', exact: true }).click() + const creator = page.getByRole('dialog', { name: '创造模式', exact: true }) + await creator.getByRole('heading', { name: '添加一个界面', exact: true }).waitFor() + expect(await creator.getByRole('heading', { name: '添加一个工具', exact: true }).isVisible()).toBe(true) + const closePosition = await creator.getByRole('button', { name: '关闭', exact: true }).boundingBox() + const examples = creator.getByRole('tabpanel', { name: '如何使用', exact: true }) + const scrolledTo = await examples.evaluate((element) => { + element.scrollTop = element.scrollHeight + return element.scrollTop + }) + expect(scrolledTo).toBeGreaterThan(0) + expect(await creator.getByRole('button', { name: '关闭', exact: true }).boundingBox()).toEqual(closePosition) + await creator.getByRole('tab', { name: '模式说明', exact: true }).click() + await creator.getByRole('tab', { name: '如何使用', exact: true }).click() + expect(await examples.evaluate(element => element.scrollTop)).toBe(scrolledTo) + await creator.getByRole('button', { name: '关闭', exact: true }).click() + await creator.waitFor({ state: 'detached' }) + }, 60_000) + it('views a shipped composition read-only instead of editing it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-view')) const dialog = settingsDialog() - await dialog.getByRole('button', { name: '查看: 标准模式' }).click() - const viewer = page.getByRole('dialog', { name: '查看 · 标准模式' }) + await dialog.getByRole('button', { name: '查看配置: 标准模式' }).click() + const viewer = page.getByRole('dialog', { name: '查看配置 · 标准模式' }) await viewer.waitFor({ timeout: 10_000 }) // The real shipped composition, not a golden: the viewer shows whatever @@ -118,7 +156,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { it('copies 极简模式 whole under a new id and lands in its files', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-copy')) const dialog = settingsDialog() - await dialog.getByRole('button', { name: '复制: 极简模式' }).click() + await dialog.getByRole('button', { name: '复制预设: 极简模式' }).click() const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' }) await copyDialog.waitFor({ timeout: 10_000 }) @@ -131,7 +169,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { expect(dialogSnapshot).not.toContain('描述') await copyDialog.getByPlaceholder('my-agent').fill('my-agent') - await copyDialog.getByPlaceholder('选择器中显示的名字,缺省用标识符').fill('我的模式') + await copyDialog.getByPlaceholder('在模式列表中显示的名称,不填则使用标识符').fill('我的模式') await copyDialog.getByRole('button', { name: '创建' }).click() await copyDialog.waitFor({ state: 'detached', timeout: 10_000 }) @@ -175,7 +213,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { // The custom group outlives its only member: the heading stays with the // creator entry so the place to author a preset never disappears. expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(1) - expect(await dialog.getByRole('button', { name: '用「创造模式」创作自定义预设' }).count()).toBe(1) + expect(await dialog.getByRole('button', { name: '让 Agent 帮我创建预设模式' }).count()).toBe(1) expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0) }, 60_000) @@ -205,10 +243,10 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { expect(snapshot).toContain('not valid YAML') expect(snapshot).toContain('agent.cordis.yml is missing') expect(await dialog.getByRole('button', { name: '加载失败: broken-yaml' }).isDisabled()).toBe(true) - expect(await dialog.getByRole('button', { name: '复制: 幽灵预设' }).isDisabled()).toBe(true) + expect(await dialog.getByRole('button', { name: '复制预设: 幽灵预设' }).isDisabled()).toBe(true) // A broken card offers no "set default" affordance at all — the aria name // IS the broken marking, so the picking name must not exist. - expect(await dialog.getByRole('button', { name: '设为默认: broken-yaml' }).count()).toBe(0) + expect(await dialog.getByRole('button', { name: '设为新任务默认: broken-yaml' }).count()).toBe(0) // The ghost's way out is the card's own delete — and the id it blocked // is claimable again immediately afterwards. @@ -220,13 +258,13 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { await expect.poll(async () => dialog.getByText('幽灵预设').count(), { timeout: 10_000 }).toBe(0) expect(existsSync(join(userRoot, 'ghost'))).toBe(false) - await dialog.getByRole('button', { name: '复制: 极简模式' }).click() + await dialog.getByRole('button', { name: '复制预设: 极简模式' }).click() const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' }) await copyDialog.waitFor({ timeout: 10_000 }) await copyDialog.getByPlaceholder('my-agent').fill('ghost') await copyDialog.getByRole('button', { name: '创建' }).click() await copyDialog.waitFor({ state: 'detached', timeout: 10_000 }) - await dialog.getByRole('button', { name: '设为默认: ghost' }).waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: '设为新任务默认: ghost' }).waitFor({ timeout: 10_000 }) // Leave the roster as the earlier tests shaped it. await dialog.getByRole('button', { name: '删除: ghost' }).click() @@ -248,7 +286,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { const dialog = settingsDialog() await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'Agent 预设' }).click() - await dialog.getByRole('button', { name: '用「创造模式」创作自定义预设' }).click() + await dialog.getByRole('button', { name: '让 Agent 帮我创建预设模式' }).click() // Leaving settings is part of the gesture: the flow lands on the // new-session screen with the self-referential preset staged, and the diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index b7facbb598..6f9a7d8044 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -265,7 +265,7 @@ describe('web e2e: agent-preset selection', () => { await page.getByRole('button', { name: 'Settings', exact: true }).click() const dialog = page.getByRole('dialog', { name: 'Settings' }) await dialog.getByRole('button', { name: 'Agent presets' }).click() - const toggle = dialog.getByRole('switch', { name: 'Allow switching Agent modes' }) + const toggle = dialog.getByRole('switch', { name: 'Choose a mode for new tasks' }) await dialog.getByRole('button', { name: 'New task default: Standard mode' }).waitFor({ timeout: 10_000 }) expect(await toggle.getAttribute('aria-checked')).toBe('true') await dialog.getByRole('button', { name: 'Close' }).last().click() @@ -365,13 +365,13 @@ describe('web e2e: agent-preset selection', () => { await page.getByRole('button', { name: 'Settings', exact: true }).click() const dialog = page.getByRole('dialog', { name: 'Settings' }) await dialog.getByRole('button', { name: 'Agent presets' }).click() - await dialog.getByRole('button', { name: 'Set as default: Minimal mode' }).click() + await dialog.getByRole('button', { name: 'Set as new task default: Minimal mode' }).click() await dialog.getByRole('button', { name: 'New task default: Minimal mode' }).waitFor({ timeout: 10_000 }) await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal') - const toggle = dialog.getByRole('switch', { name: 'Allow switching Agent modes' }) + const toggle = dialog.getByRole('switch', { name: 'Choose a mode for new tasks' }) await toggle.click() await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false') - await dialog.getByRole('button', { name: 'Default: Standard mode' }).waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: 'Application default: Standard mode' }).waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'Close' }).last().click() await expect.poll(() => page.getByRole('button', { name: / mode$/ }).count()).toBe(0) @@ -382,7 +382,7 @@ describe('web e2e: agent-preset selection', () => { await page.getByRole('button', { name: 'Settings', exact: true }).click() const reopened = page.getByRole('dialog', { name: 'Settings' }) await reopened.getByRole('button', { name: 'Agent presets' }).click() - const reopenedToggle = reopened.getByRole('switch', { name: 'Allow switching Agent modes' }) + const reopenedToggle = reopened.getByRole('switch', { name: 'Choose a mode for new tasks' }) await reopenedToggle.click() await expect.poll(() => reopenedToggle.getAttribute('aria-checked')).toBe('true') await reopened.getByRole('button', { name: 'New task default: Minimal mode' }).waitFor({ timeout: 10_000 }) diff --git a/apps/web/tests/expected/agent-preset-authoring/copy-dialog.expected.md b/apps/web/tests/expected/agent-preset-authoring/copy-dialog.expected.md index 6e0cbf8c14..f3d67b4e69 100644 --- a/apps/web/tests/expected/agent-preset-authoring/copy-dialog.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/copy-dialog.expected.md @@ -1,13 +1,13 @@ - dialog "复制预设 · 复制自 极简模式": - heading "复制预设 · 复制自 极简模式" [level=2] - button "关闭" - - paragraph: 整个预设会在本机复制一份。标识符将成为目录名,事后无法更改;其余内容之后直接在预设自己的文件里编辑。 + - paragraph: 复制后会在本机保存一份预设,你可以打开目录修改配置。标识符用作目录名,创建后不能修改。 - text: 标识符 - textbox "标识符": - /placeholder: my-agent - text: 名称 - textbox "名称": - - /placeholder: 选择器中显示的名字,缺省用标识符 + - /placeholder: 在模式列表中显示的名称,不填则使用标识符 - alert: 请填写标识符。 - button "取消" - button "创建" [disabled] diff --git a/apps/web/tests/expected/agent-preset-authoring/created.expected.md b/apps/web/tests/expected/agent-preset-authoring/created.expected.md index f55d4098d2..6a52ce25ac 100644 --- a/apps/web/tests/expected/agent-preset-authoring/created.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/created.expected.md @@ -8,46 +8,59 @@ - button "打开配置文件" - button "关闭" - heading "Agent 预设" [level=2] - - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。 - - text: 允许切换agent模式 beta - - paragraph: 开启后,新任务可选择标准、PTC、创造、极简及自定义模式;关闭后统一使用默认模式(默认为标准模式,可自定义)。仅影响新任务。 - - switch "允许切换agent模式" [checked] + - paragraph: 选择 Agent 的工具和工作方式。日常任务用「标准模式」,扩展 DSH 的能力用「创造模式」。 + - text: 新任务可选择模式 Beta + - paragraph: 开启后,可为每个新任务选择模式,并在这里设置默认值。关闭后,新任务使用应用配置的默认预设。已有任务不受影响。 + - switch "新任务可选择模式" [checked] - heading "内置" [level=3] - list: - listitem: - 'button "新任务默认: 标准模式" [disabled] [pressed]': - - text: 标准模式 内置 新任务默认 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。 + - text: 标准模式 新任务默认 - code: standard - - 'button "查看: 标准模式"': 查看 - - 'button "复制: 标准模式"': 复制 + - text: 处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。 + - 'button "模式说明: 标准模式"': 模式说明 + - 'button "如何使用: 标准模式"': 如何使用 + - 'button "查看配置: 标准模式"': 查看配置 + - 'button "复制预设: 标准模式"': 复制预设 - listitem: - - 'button "设为默认: PTC 模式"': - - text: PTC 模式 内置 功能完整的编码 Agent,但默认不提供 workflow 工具;其他工具通过 PTC 模式 SDK 呈现,让模型用一个 TypeScript 程序组合多步操作。 + - 'button "设为新任务默认: PTC 模式"': + - text: PTC 模式 内置 - code: ptc - - 'button "查看: PTC 模式"': 查看 - - 'button "复制: PTC 模式"': 复制 + - text: 包含标准模式的所有能力,让 Agent 用代码串联多个工具,完成批量处理、筛选和汇总。你只需描述任务。 + - 'button "模式说明: PTC 模式"': 模式说明 + - 'button "如何使用: PTC 模式"': 如何使用 + - 'button "查看配置: PTC 模式"': 查看配置 + - 'button "复制预设: PTC 模式"': 复制预设 - listitem: - - 'button "设为默认: 极简模式"': - - text: 极简模式 内置 仅提供持久 shell 的单工具编码 Agent。 + - 'button "设为新任务默认: 极简模式"': + - text: 极简模式 内置 - code: minimal - - 'button "查看: 极简模式"': 查看 - - 'button "复制: 极简模式"': 复制 + - text: Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。 + - 'button "模式说明: 极简模式"': 模式说明 + - 'button "如何使用: 极简模式"': 如何使用 + - 'button "查看配置: 极简模式"': 查看配置 + - 'button "复制预设: 极简模式"': 复制预设 - listitem: - - 'button "设为默认: 创造模式"': - - text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、持久化插件管理和 preset 创作指导。 + - 'button "设为新任务默认: 创造模式"': + - text: 创造模式 内置 - code: cordis - - 'button "查看: 创造模式"': 查看 - - 'button "复制: 创造模式"': 复制 + - text: 用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。 + - 'button "模式说明: 创造模式"': 模式说明 + - 'button "如何使用: 创造模式"': 如何使用 + - 'button "查看配置: 创造模式"': 查看配置 + - 'button "复制预设: 创造模式"': 复制预设 - heading "自定义" [level=3] - list: - listitem: - - 'button "设为默认: 我的模式"': - - text: 我的模式 自定义 仅提供持久 shell 的单工具编码 Agent。 + - 'button "设为新任务默认: 我的模式"': + - text: 我的模式 自定义 - code: my-agent + - text: 仅提供持久 shell 的单工具编码 Agent。 - 'button "查看路径: 我的模式"': 查看路径 - - 'button "复制: 我的模式"': 复制 + - 'button "复制预设: 我的模式"': 复制预设 - 'button "删除: 我的模式"': 删除 - paragraph: - text: 预设文件: - code: {{presetRoot}}/my-agent - - button "用「创造模式」创作自定义预设" + - button "让 Agent 帮我创建预设模式" diff --git a/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md b/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md index 2d4db679fb..3a032b92f1 100644 --- a/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md @@ -8,52 +8,66 @@ - button "打开配置文件" - button "关闭" - heading "Agent 预设" [level=2] - - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。 - - text: 允许切换agent模式 beta - - paragraph: 开启后,新任务可选择标准、PTC、创造、极简及自定义模式;关闭后统一使用默认模式(默认为标准模式,可自定义)。仅影响新任务。 - - switch "允许切换agent模式" [checked] + - paragraph: 选择 Agent 的工具和工作方式。日常任务用「标准模式」,扩展 DSH 的能力用「创造模式」。 + - text: 新任务可选择模式 Beta + - paragraph: 开启后,可为每个新任务选择模式,并在这里设置默认值。关闭后,新任务使用应用配置的默认预设。已有任务不受影响。 + - switch "新任务可选择模式" [checked] - heading "内置" [level=3] - list: - listitem: - 'button "新任务默认: 标准模式" [disabled] [pressed]': - - text: 标准模式 内置 新任务默认 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。 + - text: 标准模式 新任务默认 - code: standard - - 'button "查看: 标准模式"': 查看 - - 'button "复制: 标准模式"': 复制 + - text: 处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。 + - 'button "模式说明: 标准模式"': 模式说明 + - 'button "如何使用: 标准模式"': 如何使用 + - 'button "查看配置: 标准模式"': 查看配置 + - 'button "复制预设: 标准模式"': 复制预设 - listitem: - - 'button "设为默认: PTC 模式"': - - text: PTC 模式 内置 功能完整的编码 Agent,但默认不提供 workflow 工具;其他工具通过 PTC 模式 SDK 呈现,让模型用一个 TypeScript 程序组合多步操作。 + - 'button "设为新任务默认: PTC 模式"': + - text: PTC 模式 内置 - code: ptc - - 'button "查看: PTC 模式"': 查看 - - 'button "复制: PTC 模式"': 复制 + - text: 包含标准模式的所有能力,让 Agent 用代码串联多个工具,完成批量处理、筛选和汇总。你只需描述任务。 + - 'button "模式说明: PTC 模式"': 模式说明 + - 'button "如何使用: PTC 模式"': 如何使用 + - 'button "查看配置: PTC 模式"': 查看配置 + - 'button "复制预设: PTC 模式"': 复制预设 - listitem: - - 'button "设为默认: 极简模式"': - - text: 极简模式 内置 仅提供持久 shell 的单工具编码 Agent。 + - 'button "设为新任务默认: 极简模式"': + - text: 极简模式 内置 - code: minimal - - 'button "查看: 极简模式"': 查看 - - 'button "复制: 极简模式"': 复制 + - text: Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。 + - 'button "模式说明: 极简模式"': 模式说明 + - 'button "如何使用: 极简模式"': 如何使用 + - 'button "查看配置: 极简模式"': 查看配置 + - 'button "复制预设: 极简模式"': 复制预设 - listitem: - - 'button "设为默认: 创造模式"': - - text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、持久化插件管理和 preset 创作指导。 + - 'button "设为新任务默认: 创造模式"': + - text: 创造模式 内置 - code: cordis - - 'button "查看: 创造模式"': 查看 - - 'button "复制: 创造模式"': 复制 + - text: 用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。 + - 'button "模式说明: 创造模式"': 模式说明 + - 'button "如何使用: 创造模式"': 如何使用 + - 'button "查看配置: 创造模式"': 查看配置 + - 'button "复制预设: 创造模式"': 复制预设 - heading "自定义" [level=3] - list: - listitem: - 'button "加载失败: broken-yaml" [disabled]': - - text: broken-yaml 加载失败 自定义 暂无描述。 - - alert: "the composition is not valid YAML: unexpected end of the stream within a flow collection (3:1)" + - text: broken-yaml 加载失败 自定义 - code: broken-yaml + - text: 暂无描述。 + - alert: "the composition is not valid YAML: unexpected end of the stream within a flow collection (3:1)" - 'button "查看路径: broken-yaml"': 查看路径 - - 'button "复制: broken-yaml" [disabled]': 预设加载失败,不能复制 + - 'button "复制预设: broken-yaml" [disabled]': 预设加载失败,不能复制 - 'button "删除: broken-yaml"': 删除 - listitem: - 'button "加载失败: 幽灵预设" [disabled]': - - text: 幽灵预设 加载失败 自定义 composition 已被手动删除。 - - alert: the composition file agent.cordis.yml is missing — the directory still occupies the id; delete it or restore the file + - text: 幽灵预设 加载失败 自定义 - code: ghost + - text: composition 已被手动删除。 + - alert: the composition file agent.cordis.yml is missing — the directory still occupies the id; delete it or restore the file - 'button "查看路径: 幽灵预设"': 查看路径 - - 'button "复制: 幽灵预设" [disabled]': 预设加载失败,不能复制 + - 'button "复制预设: 幽灵预设" [disabled]': 预设加载失败,不能复制 - 'button "删除: 幽灵预设"': 删除 - - button "用「创造模式」创作自定义预设" + - button "让 Agent 帮我创建预设模式" diff --git a/apps/web/tests/expected/agent-preset-authoring/section.expected.md b/apps/web/tests/expected/agent-preset-authoring/section.expected.md index 077c88b625..20ee272950 100644 --- a/apps/web/tests/expected/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/section.expected.md @@ -8,35 +8,47 @@ - button "打开配置文件" - button "关闭" - heading "Agent 预设" [level=2] - - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。 - - text: 允许切换agent模式 beta - - paragraph: 开启后,新任务可选择标准、PTC、创造、极简及自定义模式;关闭后统一使用默认模式(默认为标准模式,可自定义)。仅影响新任务。 - - switch "允许切换agent模式" [checked] + - paragraph: 选择 Agent 的工具和工作方式。日常任务用「标准模式」,扩展 DSH 的能力用「创造模式」。 + - text: 新任务可选择模式 Beta + - paragraph: 开启后,可为每个新任务选择模式,并在这里设置默认值。关闭后,新任务使用应用配置的默认预设。已有任务不受影响。 + - switch "新任务可选择模式" [checked] - heading "内置" [level=3] - list: - listitem: - 'button "新任务默认: 标准模式" [disabled] [pressed]': - - text: 标准模式 内置 新任务默认 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。 + - text: 标准模式 新任务默认 - code: standard - - 'button "查看: 标准模式"': 查看 - - 'button "复制: 标准模式"': 复制 + - text: 处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。 + - 'button "模式说明: 标准模式"': 模式说明 + - 'button "如何使用: 标准模式"': 如何使用 + - 'button "查看配置: 标准模式"': 查看配置 + - 'button "复制预设: 标准模式"': 复制预设 - listitem: - - 'button "设为默认: PTC 模式"': - - text: PTC 模式 内置 功能完整的编码 Agent,但默认不提供 workflow 工具;其他工具通过 PTC 模式 SDK 呈现,让模型用一个 TypeScript 程序组合多步操作。 + - 'button "设为新任务默认: PTC 模式"': + - text: PTC 模式 内置 - code: ptc - - 'button "查看: PTC 模式"': 查看 - - 'button "复制: PTC 模式"': 复制 + - text: 包含标准模式的所有能力,让 Agent 用代码串联多个工具,完成批量处理、筛选和汇总。你只需描述任务。 + - 'button "模式说明: PTC 模式"': 模式说明 + - 'button "如何使用: PTC 模式"': 如何使用 + - 'button "查看配置: PTC 模式"': 查看配置 + - 'button "复制预设: PTC 模式"': 复制预设 - listitem: - - 'button "设为默认: 极简模式"': - - text: 极简模式 内置 仅提供持久 shell 的单工具编码 Agent。 + - 'button "设为新任务默认: 极简模式"': + - text: 极简模式 内置 - code: minimal - - 'button "查看: 极简模式"': 查看 - - 'button "复制: 极简模式"': 复制 + - text: Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。 + - 'button "模式说明: 极简模式"': 模式说明 + - 'button "如何使用: 极简模式"': 如何使用 + - 'button "查看配置: 极简模式"': 查看配置 + - 'button "复制预设: 极简模式"': 复制预设 - listitem: - - 'button "设为默认: 创造模式"': - - text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、持久化插件管理和 preset 创作指导。 + - 'button "设为新任务默认: 创造模式"': + - text: 创造模式 内置 - code: cordis - - 'button "查看: 创造模式"': 查看 - - 'button "复制: 创造模式"': 复制 + - text: 用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。 + - 'button "模式说明: 创造模式"': 模式说明 + - 'button "如何使用: 创造模式"': 如何使用 + - 'button "查看配置: 创造模式"': 查看配置 + - 'button "复制预设: 创造模式"': 复制预设 - heading "自定义" [level=3] - - button "用「创造模式」创作自定义预设" + - button "让 Agent 帮我创建预设模式" diff --git a/apps/web/tests/expected/agent-preset-selection/menu.expected.md b/apps/web/tests/expected/agent-preset-selection/menu.expected.md index d6dfa09aad..a765eb87ac 100644 --- a/apps/web/tests/expected/agent-preset-selection/menu.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/menu.expected.md @@ -1,6 +1,6 @@ - menu: - - menuitem "Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows." - - menuitem "PTC mode Full coding agent without the workflow tool; other tools are exposed through the PTC mode SDK so the model can combine multi-step operations in one TypeScript program." - - menuitem "Minimal mode Single-tool coding agent with a persistent shell." - - menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, persistent plugin management, and preset-authoring guidance." + - menuitem "Standard mode Work with code, files, and information. Suitable for most tasks, with search, editing, terminal commands, and other tools available as needed." + - menuitem "PTC mode Includes all Standard mode capabilities, with the agent connecting tools through code for batch processing, filtering, and summaries. Just describe your task." + - menuitem "Minimal mode The agent works using only a terminal tool. Useful for testing and comparing its basic performance." + - menuitem "Creator mode Customize DSH through conversation. Let the agent write plugins that add features or UI, or combine tools and prompts to create your own mode." - menuitem "Refusing mode Resolves, then refuses to start." diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 3b67b4ca8a..f4b6a9b41d 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/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-agent-preset/README.md -README.md: 5f2c9acaa5840e71d6ca237c5fc5eccb52724cda -README.zh.md: 5c61823c25f145c0e516f3f2a16c2de05c250ef1 +README.md: e1fb0bff3c35255c610347a8d50c7ff642cad918 +README.zh.md: 9b462d42f315c8dc9b78949de28df43cc0e5bad0 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 5f2c9acaa5..e1fb0bff3c 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -33,6 +33,8 @@ Mount this plugin alongside the settings and conversation packages; once Develop The settings section shows the roster as cards: a copy dialog is the only way a preset is created — the browser edits no composition text — and every custom card keeps a location action that opens the preset's own files. The visibility switch changes only whether the saved user default is active: the Host uses the deployment default while hidden and restores the saved default when the picker is shown again. While the picker is enabled, choosing a healthy non-default card writes a new user default for later sessions; if the current new-task surface already reuses a blank session, that explicit Settings action carries the same preset to that exact blank session through the existing selection path. Started and historical sessions remain unchanged. The switch is disabled while saving, and a failed write keeps the prior preference and shows an error. Hiding the picker disables default selection and the Creator launch but leaves roster viewing, copying, location, and deletion available. Deleting removes the preset directory while sessions already composed from it keep running. A shipped preset opens in a read-only viewer and offers no location or delete. A roster row carrying `broken` renders as a marked card whose body and duplication are disabled, because a copy of a broken preset is another broken preset; broken custom rows keep their location and delete actions so the files can be fixed and ghost directories cleared. The card face still shows the preset's own description — a chooser cannot act on a package specifier there — and the host's reason rides the badge as a tooltip, plus a visually hidden alert that carries it to assistive technology, which a disabled card body cannot. +Known shipped presets offer mode details and usage examples in a read-only dialog. Its tabs preserve each page's scroll position; closing returns focus to the opening action. Help does not change the new-task default. The default badge replaces the card's source badge, and the preset id appears beside the title. Guide copy and examples belong to this package; the tab control comes from `ui-primitives`. + ### The conversational entry When the roster carries the self-referential `cordis` preset, its dashed add-card stays disabled until the picker is enabled. It then stages `cordis` and starts a new session — the section closes the settings panel and the new-session chip's own applier composes the blank session the workspace flow produces. diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 5c61823c25..9b462d42f3 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -33,6 +33,8 @@ kind: "package-reference" 设置分区把名单呈现为卡片:复制对话框是创建 preset 的唯一入口——浏览器不编辑任何组装文本——每张自定义卡片都保留一个打开 preset 自身文件的位置动作。可见性开关只决定已保存的用户默认值是否生效:宿主在隐藏期间使用部署默认值,再次显示选择器时恢复已保存的默认值。选择器开启期间,选择健康且非默认的卡片会为后续会话写入新的用户默认值;如果当前新任务页已经复用一个空白会话,这次在设置中的明确选择也会通过既有选择链路把同一 preset 带到这个精确的空白会话。已开始及历史会话保持不变。保存期间开关会被禁用;写入失败时,界面保留先前的偏好并显示错误。隐藏选择器会禁用默认值选择与 Creator 启动,但名单查看、复制、位置和删除仍然可用。删除会移除 preset 目录,而已据其组装的会话继续运行。随附 preset 在只读查看器中打开,不提供位置或删除。名单行携带 `broken` 时渲染为标记卡片,其主体与复制均被禁用,因为损坏 preset 的副本只是另一个损坏 preset;损坏的自定义行保留位置与删除动作,以便修复文件、清掉幽灵目录。卡片正面仍显示 preset 自己的描述——在选择器里,一个包说明符不足以让人采取行动——宿主给出的原因作为工具提示附在徽标上,另有一个视觉隐藏的 alert 将该原因传达给辅助技术,而被禁用的卡片主体无法做到这一点。 +已知的内置预设提供模式说明与使用示例,通过只读弹窗展示。标签切换保留各页的滚动位置,关闭后焦点回到打开入口。查看帮助不会改变新任务默认模式。默认标签替换卡片的来源标签,预设标识显示在标题旁。说明文案和案例由本包维护,标签切换控件来自 `ui-primitives`。 + ### 对话式入口 名单携带自指的 `cordis` preset 时,其虚线添加卡在选择器开启前保持禁用。开启后,它会暂存 `cordis` 并启动新会话——分区关闭设置面板,新建会话 chip 自己的应用器负责组装工作区流程产出的空白会话。 diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css index 91111b0682..76e6b536e3 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -84,9 +84,7 @@ padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(268px, 1fr)); - /* Every row the same height, so a short description does not make its card - shorter than the one beside it. */ - grid-auto-rows: 1fr; + /* Match neighboring cards within a row, without stretching shorter rows. */ gap: 12px; } @@ -214,7 +212,7 @@ cursor: pointer; display: flex; flex-direction: column; - gap: 8px; + gap: 12px; padding: 14px 16px 12px; border-radius: 12px 12px 0 0; } @@ -230,38 +228,45 @@ .cardHead { display: flex; - align-items: center; - gap: 8px; + align-items: flex-start; + gap: 12px; /* Anchors the broken badge's tooltip: the badge itself stays unpositioned so - its `::after` resolves against the card's own width instead of against a + the tooltip resolves against the card's own width instead of against a badge that sits partway across it. A tooltip grown from the badge would run past the card, and past the section for a card in the last column. */ position: relative; } +.cardIdentity { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 6px; +} + .cardName { + min-width: 0; font-size: 15px; font-weight: 600; line-height: 1.4; -} - -/* Placement only: the capsule geometry and palette come from `Tag`. */ -.inUse { - margin-left: auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } /* Bounded to four lines. A preset publishes its own description, so one long - one would otherwise stretch every card in its grid row (`.cards` sizes rows - 1fr). Clamping is CSS alone: the whole text stays in the DOM for assistive + one would otherwise stretch every card in its grid row. Clamping is CSS + alone: the whole text stays in the DOM for assistive tech, and the card offers it on hover when it is actually cut off. The description does not grow to fill the card — `-webkit-line-clamp` on a flex-stretched box leaves the clamp height and the box height disagreeing, - so `.cardId` takes the free space with an auto margin instead. */ + so auto margins center the text between the header and footer instead. */ .cardDesc { + margin-block: auto; font-size: 13px; line-height: 1.55; color: var(--dsw-alias-label-secondary); - min-height: 42px; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 4; @@ -271,20 +276,47 @@ } .cardId { - margin-top: auto; + flex-shrink: 0; + max-width: 35%; font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 11px; + line-height: 21px; color: var(--dsw-alias-label-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .cardFoot { display: flex; + align-items: center; + flex-wrap: wrap; justify-content: flex-end; gap: 2px; padding: 6px 10px; border-top: 0.5px solid var(--dsw-alias-border-l2); } +.cardHelp { + display: flex; + align-items: center; + gap: 4px; + margin-right: auto; +} + +.helpButton { + padding: 5px 6px; + min-height: 28px; + font-size: 12px; + font-weight: 400; + color: var(--dsw-alias-label-tertiary); +} + +.helpButton:hover, +.helpButton:focus-visible { + color: var(--dsw-alias-label-primary); +} + /* Icon-only actions: the label rides `title` so the row stays quiet until someone reaches for it. */ .iconButton { diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index d05fa305bf..cc3e9157ff 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -20,6 +20,7 @@ import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client- import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { draftBlocker, type AgentPresetSectionState } from './section-store.ts' import { presetDisplayText, type AgentPresetSettingsKey } from './locales.ts' +import { PresetGuideDialog, presetGuide, type PresetGuidePage } from './PresetGuideDialog.tsx' import css from './AgentPresetSection.module.css' /** Registration-side business face for the management section. */ @@ -184,6 +185,10 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { const { useAgentPresetSection, t, load } = props const state = useAgentPresetSection(snapshot => snapshot) const developerTools = props.useDeveloperTools(enabled => enabled) + const [guide, setGuide] = useState<{ + content: NonNullable> + page: PresetGuidePage + } | null>(null) const viewedId = state.view?.id const viewedRow = viewedId === undefined ? undefined : state.rows.find(row => row.id === viewedId) const viewedTitle = state.view === null @@ -263,6 +268,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { .map(row => ({ row, text: presetDisplayText(row, t), + help: presetGuide(row.id, row.trust), selectionAction: row.broken !== undefined ? t('brokenBadge') : row.isDefault @@ -278,7 +284,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {

{heading}

{group.length === 0 ? null : (
    - {group.map(({ row, text, selectionAction }) => ( + {group.map(({ row, text, selectionAction, help }) => (
  • - {text.name} - {row.broken !== undefined - ? ( - - {t('brokenBadge')} - {/* Pointer-only, hence `aria-hidden`: the same - reason reaches assistive technology through the - alert below, and a second copy inside the card's - own text would be read out twice. */} - - - ) - : null} - - {row.trust === 'user' ? t('userTrust') : t('builtIn')} - - {row.isDefault - ? ( - - {state.showPicker ? t('inUse') : t('selectionOffDefault')} - - ) - : null} + + {text.name} + {row.broken !== undefined + ? ( + + {t('brokenBadge')} + {/* Pointer-only, hence `aria-hidden`: the same + reason reaches assistive technology through the + alert below, and a second copy inside the card's + own text would be read out twice. */} + + + ) + : null} + + {row.isDefault + ? state.showPicker ? t('inUse') : t('selectionOffDefault') + : row.trust === 'user' ? t('userTrust') : t('builtIn')} + + + {row.id} {/* Visually hidden, deliberately: the pointer path is the @@ -354,9 +358,28 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { {row.broken === undefined ? null : {row.broken}} - {row.id}
    + {help === undefined ? null : ( +
    + + +
    + )} {/* Shipped presets are the compositions a copy starts from, so READING one is the point; a custom preset is edited in its files instead, which the location action @@ -431,6 +454,14 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { ) })} + {guide === null ? null : ( + { setGuide(null) }} + /> + )} ([ + ['standard', { name: 'presetStandardName', intro: 'guideStandardIntro', explanation: 'guideStandardExplanation', usage: 'guideStandardUsage' }], + ['ptc', { name: 'presetPtcName', intro: 'guidePtcIntro', explanation: 'guidePtcExplanation', usage: 'guidePtcUsage' }], + ['minimal', { name: 'presetMinimalName', intro: 'guideMinimalIntro', explanation: 'guideMinimalExplanation', usage: 'guideMinimalUsage' }], + ['cordis', { name: 'presetCordisName', intro: 'guideCordisIntro', explanation: 'guideCordisExplanation', usage: 'guideCordisUsage' }], +]) + +/** + * Look up help only for known, shipped presets. + * @param id - preset identifier from the roster. + * @param trust - roster source; custom presets own their capability claims. + * @returns the shipped guide, or undefined for unknown and custom presets. + */ +export function presetGuide(id: string, trust: string): PresetGuide | undefined { + return trust === 'system' ? guides.get(id) : undefined +} + +/** Curated usage dictionaries contain only level-three example sections. */ +function GuideUsage({ text, t }: { + text: string + t: (key: AgentPresetSettingsKey) => string +}): ReactNode { + const labels = { + code: { copyLabel: t('guideCopy'), copiedLabel: t('guideCopied') }, + footnotes: t('guideFootnotes'), + } + return text.split(/(?=^### )/m).map((section) => { + const headingEnd = section.indexOf('\n') + const title = section.slice(4, headingEnd) + return ( +
    +
    +

    {title}

    + + + {t('guideExampleTask')} + +
    + +
    + ) + }) +} + +/** + * Open read-only help without changing the selected preset. + * @param props - localized guide, initial page, and close callback. + * @returns the modal reader with independent scroll positions for each page. + */ +export function PresetGuideDialog({ guide, initialPage, t, onClose }: { + guide: PresetGuide + initialPage: PresetGuidePage + t: (key: AgentPresetSettingsKey) => string + onClose: () => void +}): ReactNode { + const [page, setPage] = useState(initialPage) + const guideId = useId() + const content = useRef(null) + + useLayoutEffect(() => { + const previous = document.activeElement + content.current?.querySelector('[role="tab"][aria-selected="true"]')?.focus() + return () => { + if (previous instanceof HTMLElement) previous.focus() + } + }, []) + + // This reader sits above Settings. Keep keyboard navigation in the reader, + // and let Escape dismiss only this layer before returning to its trigger. + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + onClose() + } else if (event.key === 'Tab') { + const targets = Array.from(event.currentTarget.querySelectorAll( + 'button:not([disabled]):not([tabindex="-1"]), [tabindex="0"]', + )).filter(element => !element.closest('[hidden]')) + const first = targets[0] + const last = targets[targets.length - 1] + if (event.shiftKey && document.activeElement === first) { + event.preventDefault() + last?.focus() + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault() + first?.focus() + } + } + } + + return ( + +
    +
    +
    +

    {t(guide.name)}

    + +
    +

    {t(guide.intro)}

    +
    + + className={css.guideTabs} + label={t('guideSections')} + value={page} + onChange={setPage} + items={[ + { value: 'explanation', label: t('modeExplanation'), id: `${guideId}-explanation-tab`, panelId: `${guideId}-explanation-panel` }, + { value: 'usage', label: t('howToUse'), id: `${guideId}-usage-tab`, panelId: `${guideId}-usage-panel` }, + ]} + /> + {(['explanation', 'usage'] as const).map(section => ( + + ))} +
    +
    + ) +} diff --git a/packages/client/ui-agent-preset/src/client/guide-locales.ts b/packages/client/ui-agent-preset/src/client/guide-locales.ts new file mode 100644 index 0000000000..c41b55aef7 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/guide-locales.ts @@ -0,0 +1,152 @@ +/** Curated help for shipped presets, kept separate from their short picker copy. */ +export const guideEn = { + modeExplanation: 'Mode details', + howToUse: 'How to use', + guideSections: 'Guide sections', + guideExampleTask: 'Example task', + guideCopy: 'Copy', + guideCopied: 'Copied', + guideFootnotes: 'Footnotes', + guideStandardIntro: 'Choose Standard mode when starting a new task. Describe what you want to accomplish, point to the relevant files, and explain how to check the result.', + guideStandardExplanation: [ + '### How it works', + 'The agent calls tools directly to read and edit files, search, and run terminal commands. It includes Skills, planning, goals, subagents, workflows, and context compaction.', + '### When to choose it', + 'Start here for everyday coding, file work, and research. Standard mode can also write scripts and process files in batches. PTC changes how tool calls are organized; it is not required for batch tasks.', + ].join('\n\n'), + guideStandardUsage: [ + '### Fix a bug', + '> Find out why submitting the search form twice makes the results disappear. Fix it and run the relevant tests. Explain the cause and what changed.', + 'Expected output: a code change, the relevant test results, and an explanation of the cause.', + '### Organize project notes', + '> Read the Markdown notes in this project. Summarize the agreed decisions and open questions, with links to the source files.', + 'Expected output: a summary with references that you can check against the original notes.', + ].join('\n\n'), + guidePtcIntro: 'Choose PTC mode when starting a new task. Specify the input files, processing rules, and output format. The agent writes the code.', + guidePtcExplanation: [ + '### How tools are called', + 'PTC means Programmatic Tool Calling. In this built-in preset, the agent uses run_code to write a TypeScript program that calls tools through a generated SDK. The program can use loops, conditions, error handling, and concurrent calls where appropriate.', + '### What reaches the model', + 'Tool results first reach the program, which can filter and combine them. The model receives what the program prints or returns; image results are attached separately. Nested tool calls are still recorded and remain subject to tool permissions.', + '### Compared with Standard mode', + 'Both modes can handle coding and batch tasks. Standard mode exposes individual tools directly; PTC organizes tool calls in code. The current PTC preset leaves the workflow tool disabled. Speed and token use depend on the task and how the program handles its results.', + ].join('\n\n'), + guidePtcUsage: [ + '### Check a set of configuration files', + '> Check all JSON files in configs/. List missing required fields and invalid values against schema.json. Save a CSV with one row per issue. Include unreadable files in the report and keep checking the rest. Leave the original files unchanged.', + 'Expected output: an issue summary and a CSV report. The program can repeat the same checks, handle individual failures, and collect the results.', + '### Summarize error logs', + '> Analyze the log files in logs/. Group errors by service and error type. Show the ten most frequent groups and one example from each. Save the full counts to a CSV.', + 'Expected output: the top error groups and a complete count table. Intermediate data can be aggregated in the program before the summary reaches the model.', + ].join('\n\n'), + guideMinimalIntro: 'Choose Minimal mode for a new task. For a comparison, hold the model, permissions, input, and starting workspace state constant across runs.', + guideMinimalExplanation: [ + '### What is included', + 'One persistent shell tool and a fixed system prompt. The built-in preset does not load Skills, planning, context compaction, or the standard runtime context.', + '### When to choose it', + 'Use it as a baseline for experiments and comparisons. It can still read files and execute scripts through shell commands, but offers fewer built-in ways to manage a long task. Fewer tools does not necessarily make it easier for a beginner.', + ].join('\n\n'), + guideMinimalUsage: [ + '### Compare performance on a small bug fix', + '> Run the tests for this project, find the cause of the failure, and make the smallest fix. Run the relevant tests again and report the result.', + 'Run the same task separately in Standard and Minimal modes from the same starting state. Compare task completion, tool calls, and the resulting changes. Minimal mode performs the work through terminal commands.', + ].join('\n\n'), + guideCordisIntro: 'Choose Creator mode for a new task. Describe the capability you want, where it should appear, and how you will verify it.', + guideCordisExplanation: [ + '### What you can create', + 'Creator mode includes the standard task tools plus runtime inspection, persistent plugin management, and guidance for authoring Cordis plugins and agent presets. It can create a plugin that adds a capability or UI, or a preset that combines tools and prompts for a particular job.', + '### Plugins and modes', + 'A plugin adds capabilities to DSH, such as a tool, a service connection, or a UI entry. A mode is an agent preset that selects tools and defines how the agent works in a task. A plugin can be included in a custom preset.', + '### How the result takes effect', + 'Ask the agent to install and verify the result, not just generate source code. A plugin may load immediately or require a restart, depending on what it changes. A newly created preset is selected when starting a new task.', + ].join('\n\n'), + guideCordisUsage: [ + '### Add a UI', + '> Create a DSH plugin that adds a project notes entry to the sidebar. Let me browse the Markdown files in this workspace and preview a selected note. Install it and verify that the page opens.', + 'Expected output: an installed plugin with a working entry and preview page, plus any remaining activation steps.', + '### Add a tool', + '> Create a plugin with a tool that reads this project’s test report and summarizes the failed tests. Register it and verify it with a sample report.', + 'Expected output: a plugin with a callable tool and a verified sample call.', + '### Create my own mode', + '> Create a “Code review” mode based on Standard mode. Have it prioritize potential bugs and test gaps, cite file paths and lines, and ask before modifying files. Save it as a selectable preset.', + 'Expected output: a custom preset for new tasks. These review instructions guide the agent; permission settings determine which actions it can execute.', + ].join('\n\n'), +} + +/** Keys merged into the feature’s ordinary locale seat. */ +export type PresetGuideKey = keyof typeof guideEn + +/** Simplified Chinese help. Examples describe suggested tasks, not recorded runs. */ +export const guideZh: Record = { + modeExplanation: '模式说明', + howToUse: '如何使用', + guideSections: '帮助内容', + guideExampleTask: '示例任务', + guideCopy: '复制', + guideCopied: '已复制', + guideFootnotes: '脚注', + guideStandardIntro: '新建任务时选择「标准模式」,说明要完成什么、相关文件在哪里,以及怎样判断任务完成。', + guideStandardExplanation: [ + '### 工作方式', + 'Agent 直接调用工具来读写文件、检索资料和执行终端命令。包含 Skills、计划、目标、子 Agent、工作流和上下文压缩等能力。', + '### 什么时候选', + '日常编程、文件处理和资料整理可以从这里开始。标准模式也能编写脚本、批量处理文件;PTC 改变的是工具调用方式,批量任务并不必须使用 PTC。', + ].join('\n\n'), + guideStandardUsage: [ + '### 修复一个问题', + '> 搜索表单连续提交两次后,结果会消失。请定位原因、修复问题并运行相关测试,最后说明原因和修改内容。', + '预期产出:代码修改、相关测试结果,以及问题原因说明。', + '### 整理项目资料', + '> 阅读项目中的 Markdown 记录,整理已经达成的结论和仍待确认的问题,并附上对应文件链接。', + '预期产出:一份带来源引用的总结,方便回到原文核对。', + ].join('\n\n'), + guidePtcIntro: '新建任务时选择「PTC 模式」,说明输入文件、处理规则和输出格式。代码由 Agent 编写。', + guidePtcExplanation: [ + '### 怎样调用工具', + 'PTC 是 Programmatic Tool Calling,即通过程序调用工具。当前内置预设让 Agent 通过 run_code 编写 TypeScript 程序,使用生成的工具 SDK 发起调用。程序可以组织循环、条件判断、错误处理,以及适合并发执行的调用。', + '### 哪些结果交给模型', + '工具返回的数据先交给程序,经过筛选、计算或合并,再通过输出或返回值交给模型;图片结果会另行附加。程序中的工具调用仍会被记录,也仍受工具权限约束。', + '### 与标准模式的区别', + '两种模式都能编程、批量处理文件。标准模式直接向模型提供各个工具;PTC 让模型用代码组织工具调用。当前 PTC 预设未启用 workflow 工具。速度和 token 用量取决于具体任务与结果处理方式。', + ].join('\n\n'), + guidePtcUsage: [ + '### 批量检查配置文件', + '> 检查 configs/ 下所有 JSON 文件,按照 schema.json 找出缺失字段和不合法的值。每个问题写成 CSV 中的一行;读取失败的文件也记入报告,继续检查其余文件。保留原文件。', + '预期产出:问题汇总和一份 CSV 报告。程序可以对多份文件执行相同检查,处理单个文件的失败,再汇总结果。', + '### 汇总错误日志', + '> 分析 logs/ 下的日志,按服务和错误类型统计次数,列出出现最多的十类错误,每类保留一条示例。完整统计另存为 CSV。', + '预期产出:高频错误摘要和完整统计表。中间数据可以先在程序中聚合,再把汇总交给模型。', + ].join('\n\n'), + guideMinimalIntro: '新建任务时选择「极简模式」。做对照测试时,保持模型、权限、任务输入和工作区起始状态一致。', + guideMinimalExplanation: [ + '### 保留哪些能力', + '仅提供一个持久 Shell 工具,并使用固定系统提示词。内置预设不加载 Skills、计划、上下文压缩,也不注入标准运行时上下文。', + '### 什么时候选', + '适合作为实验和对照测试的基线。Agent 仍能通过终端命令读写文件、运行脚本,但缺少管理长任务的内置辅助能力。工具少,不代表对新手更容易。', + ].join('\n\n'), + guideMinimalUsage: [ + '### 对比基础修复表现', + '> 运行这个项目的测试,找出失败原因,做最小修复,再运行相关测试并报告结果。', + '分别用标准模式和极简模式,从相同的工作区状态执行这条任务,对比完成情况、工具调用和最终修改。极简模式会通过终端命令完成这些操作。', + ].join('\n\n'), + guideCordisIntro: '新建任务时选择「创造模式」,说明希望增加什么能力、从哪里使用,以及怎样验证效果。', + guideCordisExplanation: [ + '### 可以创造什么', + '创造模式具备标准任务工具,并增加运行时检查、持久化插件管理,以及 Cordis 插件和 Agent 预设的开发指引。可以编写插件来添加功能或界面,也可以组合工具和提示词,创建适合特定任务的模式。', + '### 插件与模式的关系', + '插件为 DSH 增加能力,例如工具、服务连接或界面入口。模式是一份 Agent 预设,用来选择任务可用的工具,并约定 Agent 的工作方式。自定义模式中也可以使用自己开发的插件。', + '### 怎样让成果生效', + '可以要求 Agent 完成安装并验证实际效果。插件可能即时加载,也可能需要重启,取决于修改内容;新建的模式在创建新任务时选择。', + ].join('\n\n'), + guideCordisUsage: [ + '### 添加一个界面', + '> 帮我写一个 DSH 插件,在侧栏增加「项目笔记」入口,列出当前工作区的 Markdown 文件,点击后能预览内容。完成安装并验证页面能打开。', + '预期产出:带侧栏入口和预览页的插件,以及仍需完成的生效步骤。', + '### 添加一个工具', + '> 写一个插件,提供读取项目测试报告、汇总失败用例的工具。注册工具,并用一份示例报告验证调用结果。', + '预期产出:可调用的新工具,以及一次示例调用的验证结果。', + '### 创建自己的模式', + '> 基于标准模式创建「代码审查」模式,优先检查潜在错误和测试缺口,指出文件与行号,修改文件前先询问我。保存成可选择的预设。', + '预期产出:可在新任务中选择的自定义模式。审查要求用于指导 Agent,实际可执行的操作仍由权限设置决定。', + ].join('\n\n'), +} diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index 39ac4ed537..9e5a689f01 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -1,7 +1,10 @@ /** Locale bundles for the agent-preset hero chip, header label, and management section. */ +import { guideEn, guideZh, type PresetGuideKey } from './guide-locales.ts' + /** Locale keys these surfaces render. */ export type AgentPresetSettingsKey = + | PresetGuideKey | 'error' | 'userTrust' | 'seatHint' | 'headerHint' | 'nav' | 'sectionIntro' | 'builtIn' | 'setDefault' | 'view' | 'presetStandardName' | 'presetStandardDescription' @@ -22,38 +25,39 @@ export type AgentPresetSettingsKey = /** English copy. */ export const en: Record = { + ...guideEn, error: 'Could not load agent presets.', userTrust: 'Custom', - seatHint: 'Agent preset for the session you are about to start', - headerHint: 'The agent preset this session runs, fixed when it started', + seatHint: 'Choose the agent preset for your new task', + headerHint: 'The agent preset chosen when this task started', nav: 'Agent presets', sectionIntro: - 'A preset is the plugin composition one session\'s agent runs — its tools, prompt, and capabilities. ' - + 'Duplicate an existing one and make it yours, or let the agent draft one for you in Creator mode.', + 'Choose the agent\'s tools and how it works. Use Standard mode for everyday tasks, ' + + 'or Creator mode to add capabilities to DSH.', builtIn: 'Built-in', - setDefault: 'Set as default', - view: 'View', + setDefault: 'Set as new task default', + view: 'View configuration', presetStandardName: 'Standard mode', presetStandardDescription: - 'Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows.', + 'Work with code, files, and information. Suitable for most tasks, with search, editing, terminal commands, and other tools available as needed.', presetPtcName: 'PTC mode', presetPtcDescription: - 'Full coding agent without the workflow tool; other tools are exposed through the PTC mode SDK so the model can combine multi-step operations in one TypeScript program.', + 'Includes all Standard mode capabilities, with the agent connecting tools through code for batch processing, filtering, and summaries. Just describe your task.', presetMinimalName: 'Minimal mode', presetMinimalDescription: - 'Single-tool coding agent with a persistent shell.', + 'The agent works using only a terminal tool. Useful for testing and comparing its basic performance.', presetCordisName: 'Creator mode', presetCordisDescription: - 'Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, persistent plugin management, and preset-authoring guidance.', - duplicate: 'Duplicate', + 'Customize DSH through conversation. Let the agent write plugins that add features or UI, or combine tools and prompts to create your own mode.', + duplicate: 'Duplicate preset', duplicateUnavailable: 'This deployment has no writable preset directory', delete: 'Delete', presetId: 'Identifier', presetIdPlaceholder: 'my-agent', displayName: 'Name', - displayNamePlaceholder: 'Shown in the picker; defaults to the identifier', + displayNamePlaceholder: 'Name shown in the mode menu; leave blank to use the identifier', inUse: 'New task default', - selectionOffDefault: 'Default', + selectionOffDefault: 'Application default', builtInGroup: 'Built-in', customGroup: 'Custom', noDescription: 'No description.', @@ -61,17 +65,17 @@ export const en: Record = { brokenNoCopy: 'A preset that failed to load cannot be duplicated', switchRefused: 'Could not switch to {name}: {reason}', copyOf: 'Copied from', - composition: 'Composition (agent.cordis.yml)', + composition: 'Preset configuration (agent.cordis.yml)', cancel: 'Cancel', close: 'Close', retry: 'Retry', copyTitle: 'Duplicate preset', copyIntro: - 'The whole preset is copied on this machine. The identifier becomes its directory name and cannot ' - + 'be changed later; everything else is edited in the preset\'s own files.', + 'Save a copy on this machine, then open its folder to edit the configuration. ' + + 'The identifier becomes the folder name and cannot be changed after creation.', create: 'Create', creating: 'Creating…', - creatorDraft: 'Draft a custom preset with Creator mode', + creatorDraft: 'Let the agent help me create a preset', openLocation: 'Open folder', showLocation: 'Show location', revealedPathLabel: 'Preset files:', @@ -83,42 +87,43 @@ export const en: Record = { 'The preset directory is deleted. Sessions already running on it keep working; new sessions cannot select it.', deleteConfirm: 'Delete', deleting: 'Deleting…', - showPicker: 'Allow switching Agent modes', + showPicker: 'Choose a mode for new tasks', showPickerBeta: 'Beta', showPickerDescription: - 'When enabled, new tasks can choose Standard, PTC, Creator, Minimal, and custom modes. When disabled, all new tasks use the default mode (Standard by default; configurable). Only affects new tasks.', - enablePickerToSetDefault: 'Turn on Agent mode selection to choose a default', - enablePickerToCreate: 'Turn on Agent mode selection to start Creator mode', + 'When enabled, choose a mode for each new task and set a default here. When disabled, new tasks use the application\'s configured default. Existing tasks are unaffected.', + enablePickerToSetDefault: 'Enable mode selection for new tasks before setting a default', + enablePickerToCreate: 'Enable mode selection for new tasks before starting Creator mode', } /** Simplified Chinese copy. */ export const zh: Record = { + ...guideZh, error: '无法加载 Agent 预设。', userTrust: '自定义', - seatHint: '即将开始的这个会话所用的 Agent 预设', - headerHint: '本会话运行的 Agent 预设,开始时即固定', + seatHint: '选择新任务使用的 Agent 预设', + headerHint: '本任务的 Agent 预设,在任务开始时确定', nav: 'Agent 预设', - sectionIntro: '预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。', + sectionIntro: '选择 Agent 的工具和工作方式。日常任务用「标准模式」,扩展 DSH 的能力用「创造模式」。', builtIn: '内置', - setDefault: '设为默认', - view: '查看', + setDefault: '设为新任务默认', + view: '查看配置', presetStandardName: '标准模式', - presetStandardDescription: '功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。', + presetStandardDescription: '处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。', presetPtcName: 'PTC 模式', - presetPtcDescription: '功能完整的编码 Agent,但默认不提供 workflow 工具;其他工具通过 PTC 模式 SDK 呈现,让模型用一个 TypeScript 程序组合多步操作。', + presetPtcDescription: '包含标准模式的所有能力,让 Agent 用代码串联多个工具,完成批量处理、筛选和汇总。你只需描述任务。', presetMinimalName: '极简模式', - presetMinimalDescription: '仅提供持久 shell 的单工具编码 Agent。', + presetMinimalDescription: 'Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。', presetCordisName: '创造模式', - presetCordisDescription: '用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、持久化插件管理和 preset 创作指导。', - duplicate: '复制', + presetCordisDescription: '用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。', + duplicate: '复制预设', duplicateUnavailable: '此部署未配置可写的预设目录', delete: '删除', presetId: '标识符', presetIdPlaceholder: 'my-agent', displayName: '名称', - displayNamePlaceholder: '选择器中显示的名字,缺省用标识符', + displayNamePlaceholder: '在模式列表中显示的名称,不填则使用标识符', inUse: '新任务默认', - selectionOffDefault: '默认', + selectionOffDefault: '应用默认', builtInGroup: '内置', customGroup: '自定义', noDescription: '暂无描述。', @@ -126,15 +131,15 @@ export const zh: Record = { brokenNoCopy: '预设加载失败,不能复制', switchRefused: '无法切换到「{name}」:{reason}', copyOf: '复制自', - composition: '组装(agent.cordis.yml)', + composition: '预设配置(agent.cordis.yml)', cancel: '取消', close: '关闭', retry: '重试', copyTitle: '复制预设', - copyIntro: '整个预设会在本机复制一份。标识符将成为目录名,事后无法更改;其余内容之后直接在预设自己的文件里编辑。', + copyIntro: '复制后会在本机保存一份预设,你可以打开目录修改配置。标识符用作目录名,创建后不能修改。', create: '创建', creating: '正在创建…', - creatorDraft: '用「创造模式」创作自定义预设', + creatorDraft: '让 Agent 帮我创建预设模式', openLocation: '打开目录', showLocation: '查看路径', revealedPathLabel: '预设文件:', @@ -145,11 +150,11 @@ export const zh: Record = { deleteDescription: '预设目录将被删除。已在其上运行的会话不受影响;新会话将无法再选择它。', deleteConfirm: '删除', deleting: '正在删除…', - showPicker: '允许切换agent模式', - showPickerBeta: 'beta', - showPickerDescription: '开启后,新任务可选择标准、PTC、创造、极简及自定义模式;关闭后统一使用默认模式(默认为标准模式,可自定义)。仅影响新任务。', - enablePickerToSetDefault: '请先开启 Agent 模式选择,再设置默认模式', - enablePickerToCreate: '请先开启 Agent 模式选择,再启动创造模式', + showPicker: '新任务可选择模式', + showPickerBeta: 'Beta', + showPickerDescription: '开启后,可为每个新任务选择模式,并在这里设置默认值。关闭后,新任务使用应用配置的默认预设。已有任务不受影响。', + enablePickerToSetDefault: '请先开启新任务模式选择,再设置默认值', + enablePickerToCreate: '请先开启新任务模式选择,再启动创造模式', } // The resolution itself is the shared fold in `dsh-agent-presets/display`, diff --git a/packages/client/ui-agent-preset/tests/section.client.spec.tsx b/packages/client/ui-agent-preset/tests/section.client.spec.tsx index 22de6a09cd..4bf77e08bb 100644 --- a/packages/client/ui-agent-preset/tests/section.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/section.client.spec.tsx @@ -120,11 +120,11 @@ describe('the preset list', () => { expect(within(mine).getByText(en.noDescription)).toBeTruthy() }) - it('marks trust and the one in use, and offers no "set default" on it', () => { + it('replaces the default preset trust tag with its new-task default status', () => { renderSection() const standard = rowFor('standard') - expect(within(standard).getByText(en.builtIn)).toBeTruthy() + expect(within(standard).queryByText(en.builtIn)).toBeNull() expect(within(standard).getByText(en.inUse)).toBeTruthy() expect(within(standard).queryByText(en.setDefault)).toBeNull() expect(within(rowFor('mine')).getByText(en.userTrust)).toBeTruthy() @@ -382,6 +382,108 @@ describe('the preset list', () => { }) }) +describe('preset help', () => { + it.each([ + ['standard', en.presetStandardName, 'How it works', 'Fix a bug'], + ['ptc', en.presetPtcName, 'How tools are called', 'Check a set of configuration files'], + ['minimal', en.presetMinimalName, 'What is included', 'Compare performance on a small bug fix'], + ['cordis', en.presetCordisName, 'What you can create', 'Add a UI'], + ])('opens both help sections for %s without changing the default', (id, name, heading, exampleTitle) => { + const actions = renderSection({ rows: [{ id, trust: 'system', isDefault: false }] }) + const trigger = within(rowFor(id)).getByRole('button', { name: `${en.modeExplanation}: ${name}` }) + trigger.focus() + fireEvent.click(trigger) + + const dialog = screen.getByRole('dialog', { name }) + expect(within(dialog).getByRole('heading', { name: heading })).toBeTruthy() + const usage = within(dialog).getByRole('tab', { name: en.howToUse }) + fireEvent.click(usage) + expect(usage.getAttribute('aria-selected')).toBe('true') + expect(within(dialog).getByRole('heading', { name: exampleTitle })).toBeTruthy() + expect(within(dialog).getAllByText(en.guideExampleTask).length).toBeGreaterThan(0) + fireEvent.click(within(dialog).getByRole('tab', { name: en.modeExplanation })) + expect(within(dialog).getByRole('heading', { name: heading })).toBeTruthy() + fireEvent.click(within(dialog).getByRole('button', { name: en.close })) + expect(document.activeElement).toBe(trigger) + + fireEvent.click(within(rowFor(id)).getByRole('button', { name: `${en.howToUse}: ${name}` })) + expect(within(screen.getByRole('dialog')).getByRole('tab', { name: en.howToUse }).getAttribute('aria-selected')).toBe('true') + expect(actions.makeDefault).not.toHaveBeenCalled() + expect(actions.view).not.toHaveBeenCalled() + expect(actions.startCreatorDraft).not.toHaveBeenCalled() + }) + + it('keeps keyboard focus in help and dismisses only the reader on Escape', () => { + const actions = renderSection() + const trigger = within(rowFor('standard')).getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` }) + trigger.focus() + fireEvent.click(trigger) + const dialog = screen.getByRole('dialog') + const details = within(dialog).getByRole('tab', { name: en.modeExplanation }) + const panel = within(dialog).getByRole('tabpanel', { name: en.modeExplanation }) + const close = within(dialog).getByRole('button', { name: en.close }) + expect(document.activeElement).toBe(details) + expect(fireEvent.keyDown(details, { key: 'Tab' })).toBe(true) + panel.focus() + fireEvent.keyDown(panel, { key: 'Tab' }) + expect(document.activeElement).toBe(close) + fireEvent.keyDown(close, { key: 'Tab', shiftKey: true }) + expect(document.activeElement).toBe(panel) + fireEvent.keyDown(panel, { key: 'Escape' }) + expect(screen.queryByRole('dialog')).toBeNull() + expect(document.activeElement).toBe(trigger) + expect(actions.close).not.toHaveBeenCalled() + }) + + it('closes even when the browser reports no previously focused element', () => { + const activeElement = vi.spyOn(document, 'activeElement', 'get').mockReturnValue(null) + try { + renderSection() + fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` })) + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: en.close })) + expect(screen.queryByRole('dialog')).toBeNull() + } finally { + activeElement.mockRestore() + } + }) + + it('connects keyboard selection to the visible guide panel', () => { + renderSection() + fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` })) + const dialog = screen.getByRole('dialog') + const details = within(dialog).getByRole('tab', { name: en.modeExplanation }) + const usage = within(dialog).getByRole('tab', { name: en.howToUse }) + expect(details.tabIndex).toBe(0) + expect(usage.tabIndex).toBe(-1) + fireEvent.keyDown(details, { key: 'ArrowRight' }) + const panel = within(dialog).getByRole('tabpanel', { name: en.howToUse }) + expect(panel.id).toBe(usage.getAttribute('aria-controls')) + expect(document.activeElement).toBe(usage) + expect(usage.getAttribute('aria-selected')).toBe('true') + expect(details.tabIndex).toBe(-1) + expect(usage.tabIndex).toBe(0) + expect(within(dialog).queryByRole('tabpanel', { name: en.modeExplanation })).toBeNull() + }) + + it('does not attach built-in claims to custom or unknown presets', () => { + renderSection({ + rows: [ + { id: 'ptc', trust: 'user', isDefault: false }, + { id: 'third-party', trust: 'system', isDefault: false }, + ], + }) + expect(screen.queryByRole('button', { name: new RegExp(en.modeExplanation) })).toBeNull() + expect(screen.queryByRole('button', { name: new RegExp(en.howToUse) })).toBeNull() + }) + + it('leaves help usable when mode selection is disabled', () => { + const actions = renderSection({ showPicker: false }) + fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.howToUse}: ${en.presetStandardName}` })) + expect(screen.getByRole('dialog', { name: en.presetStandardName })).toBeTruthy() + expect(actions.setPickerVisible).not.toHaveBeenCalled() + }) +}) + describe('the copy dialog', () => { const draft: CopyDraft = { from: 'standard', fromTitle: '标准模式', id: '', name: '', saving: false, error: null, diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index e6884489fc..52a8d99d49 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: f17f80d877a76d61b55b502edb05781f8e05cee4 -README.zh.md: 8540a82389b4dced4e6b6048980f4278ec5722d7 +README.md: 09950210eaef57a290362037f3d603366fe00168 +README.zh.md: aa95cbf1ce7fa79c3d1d5466ca8ed4d6942ad03e diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index f17f80d877..09950210ea 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -42,6 +42,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `Input` | Single-line text entry for search boxes and inline forms. | | `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. | | `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. | | `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. `aria-hidden`, so the render site owns the name. | | `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 8540a82389..aa95cbf1ce 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -42,6 +42,7 @@ kind: "package-library" | `Input` | 单行文本输入,用于搜索框与行内表单。 | | `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 | | `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 | +| `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。 | | `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 | | `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading。它是 `aria-hidden` 的,名称由渲染点提供。 | | `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 | diff --git a/packages/client/ui-primitives/src/SegmentedTabs.module.css b/packages/client/ui-primitives/src/SegmentedTabs.module.css new file mode 100644 index 0000000000..566e67c5e8 --- /dev/null +++ b/packages/client/ui-primitives/src/SegmentedTabs.module.css @@ -0,0 +1,53 @@ +.tabs { + position: relative; + display: grid; + padding: 4px; + border-radius: 12px; + background: var(--dsw-alias-bg-module-platform); +} + +.indicator { + position: absolute; + inset: 4px auto 4px 4px; + box-sizing: border-box; + border: 0.5px solid var(--dsw-alias-border-l3); + border-radius: 8px; + background: var(--dsw-alias-bg-layer-3); + transition: transform 180ms ease; + pointer-events: none; +} + +.tab { + position: relative; + justify-content: center; + height: 34px; + padding: 0 12px; + border-radius: 8px; + font-size: 14px; + line-height: 20px; + background: transparent; + color: var(--dsw-alias-label-secondary); + transition: color 180ms ease; +} + +.tab:hover, +.tab[aria-selected='true'] { + background: transparent; + color: var(--dsw-alias-label-primary); +} + +.tab[aria-selected='true'] { + font-weight: 600; +} + +.tab:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: -1px; +} + +@media (prefers-reduced-motion: reduce) { + .indicator, + .tab { + transition: none; + } +} diff --git a/packages/client/ui-primitives/src/SegmentedTabs.tsx b/packages/client/ui-primitives/src/SegmentedTabs.tsx new file mode 100644 index 0000000000..2ae51be8d5 --- /dev/null +++ b/packages/client/ui-primitives/src/SegmentedTabs.tsx @@ -0,0 +1,81 @@ +import type { KeyboardEvent, ReactNode } from 'react' +import clsx from 'clsx' +import { Pill } from './Pill.tsx' +import css from './SegmentedTabs.module.css' + +/** A tab links its localized label to a caller-owned panel. */ +export interface SegmentedTab { + value: Value + label: ReactNode + id: string + panelId: string +} + +/** + * Render equal-width, controlled tabs with a sliding selection indicator. + * @param props.items - non-empty ordered tabs with unique values and DOM ids. + * @param props.value - selected value, which must belong to items. + * @param props.onChange - selection requested by click, Left/Right, or Home/End. + * Keyboard selection also moves focus; only the selected tab is a tab stop. + * @param props.label - localized accessible name for the tab list. + * @param props.className - layout placement; panels remain caller-owned. + * @returns the tab list, without its panels. + */ +export function SegmentedTabs({ items, value, onChange, label, className }: { + items: readonly [SegmentedTab, ...SegmentedTab[]] + value: Value + onChange: (value: Value) => void + label: string + className?: string | undefined +}): ReactNode { + const selectedIndex = items.findIndex(item => item.value === value) + + const onKeyDown = (event: KeyboardEvent, index: number) => { + let next: number + switch (event.key) { + case 'ArrowLeft': next = (index + items.length - 1) % items.length; break + case 'ArrowRight': next = (index + 1) % items.length; break + case 'Home': next = 0; break + case 'End': next = items.length - 1; break + default: return + } + event.preventDefault() + event.stopPropagation() + const tablist = event.currentTarget.parentElement + const nextItem = items[next] + /* v8 ignore next -- the event comes from a mounted direct child and next is bounded by non-empty items. */ + if (tablist === null || nextItem === undefined) return + tablist.querySelectorAll('[role="tab"]').item(next).focus() + onChange(nextItem.value) + } + + return ( +
    +
    + ) +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index d98ad76158..25ba54ea24 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -9,6 +9,8 @@ export type { DisclosureRowProps } from './DisclosureRow.tsx' export { Button } from './Button.tsx' export type { ButtonVariant } from './Button.tsx' export { Pill } from './Pill.tsx' +export { SegmentedTabs } from './SegmentedTabs.tsx' +export type { SegmentedTab } from './SegmentedTabs.tsx' export { Tag } from './Tag.tsx' export type { TagTone } from './Tag.tsx' export { Switch } from './Switch.tsx' diff --git a/packages/client/ui-primitives/tests/SegmentedTabs.client.spec.tsx b/packages/client/ui-primitives/tests/SegmentedTabs.client.spec.tsx new file mode 100644 index 0000000000..009b86d56b --- /dev/null +++ b/packages/client/ui-primitives/tests/SegmentedTabs.client.spec.tsx @@ -0,0 +1,62 @@ +// @vitest-environment jsdom +import { useState } from 'react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SegmentedTabs } from '../src/SegmentedTabs.tsx' + +afterEach(cleanup) + +const items = [ + { value: 'details', label: 'Details', id: 'details-tab', panelId: 'details-panel' }, + { value: 'examples', label: 'Examples', id: 'examples-tab', panelId: 'examples-panel' }, + { value: 'limits', label: 'Limits', id: 'limits-tab', panelId: 'limits-panel' }, +] as const + +describe('SegmentedTabs', () => { + it('requests a selection while the caller owns its value and panels', () => { + const onChange = vi.fn() + const { rerender } = render() + const examples = screen.getByRole('tab', { name: 'Examples' }) + fireEvent.click(examples) + expect(onChange).toHaveBeenCalledWith('examples') + expect(screen.getByRole('tab', { selected: true }).textContent).toBe('Details') + expect(screen.queryByRole('tabpanel')).toBeNull() + + rerender() + expect(examples.getAttribute('aria-selected')).toBe('true') + expect(examples.id).toBe('examples-tab') + expect(examples.getAttribute('aria-controls')).toBe('examples-panel') + expect(examples.tabIndex).toBe(0) + expect(screen.getByRole('tab', { name: 'Details' }).tabIndex).toBe(-1) + expect(screen.getByRole('tablist', { name: 'Help' })).toBeTruthy() + }) + + it('moves focus and selection with arrows, Home, and End, and leaves Tab alone', () => { + const onKeyDown = vi.fn() + function Harness() { + const [value, setValue] = useState('details') + return
    + } + render() + const details = screen.getByRole('tab', { name: 'Details' }) + const examples = screen.getByRole('tab', { name: 'Examples' }) + const limits = screen.getByRole('tab', { name: 'Limits' }) + details.focus() + for (const [from, key, to] of [ + [details, 'ArrowLeft', limits], + [limits, 'ArrowRight', details], + [details, 'ArrowRight', examples], + [examples, 'End', limits], + [limits, 'Home', details], + ] as const) { + fireEvent.keyDown(from, { key }) + expect(document.activeElement).toBe(to) + expect(to.getAttribute('aria-selected')).toBe('true') + expect(to.tabIndex).toBe(0) + } + expect(onKeyDown).not.toHaveBeenCalled() + fireEvent.keyDown(details, { key: 'Tab' }) + expect(onKeyDown).toHaveBeenCalledOnce() + expect(screen.getByRole('tab', { selected: true })).toBe(details) + }) +}) From 78d6f7bc1fafe4d60fd69fab03c043d4f4a90d66 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sun, 20 Sep 2026 17:48:46 +0800 Subject: [PATCH 14/60] test(client): mock default workspace initialization during boot --- .../client-runtime/src/assembly/remote-default-responses.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts b/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts index c54130bca5..485882f05f 100644 --- a/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts +++ b/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts @@ -13,6 +13,8 @@ export const remoteDefaultResponses: RemoteTable = { unary: { // api-session-controller `sessions.handleConnected()` on `connection/reset`. 'session/list': ok({ items: [] }), + // ui-workspace startup with no entries; first-use initialization is ineligible. + 'workspace/initializeDefault': ok(undefined), // ui-settings `mirror.ensure()` at apply and again on `connection/reset`. 'settings/describe': ok({ writable: true, hasDocument: false, namespaces: [] }), // ui-model-selection `ModelDirectoryResolver` constructor. From 5294b15b3102bfd4899b13fa3fb1a4ac69419c5a Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sun, 20 Sep 2026 17:48:58 +0800 Subject: [PATCH 15/60] docs(workspace): separate constructor parameter tags --- packages/api/workspace-controller/src/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/api/workspace-controller/src/index.ts b/packages/api/workspace-controller/src/index.ts index 7a63e5b699..928f52f883 100644 --- a/packages/api/workspace-controller/src/index.ts +++ b/packages/api/workspace-controller/src/index.ts @@ -61,7 +61,10 @@ export class WorkspaceController extends TypertRemoteService { private readonly commands: WorkspaceCommands private readonly feed: WorkspaceFeed - /** @param ctx - Host context containing the Workspace registry. @param config - first-use directory policy. */ + /** + * @param ctx - Host context containing the Workspace registry. + * @param config - first-use directory policy. + */ constructor(ctx: Context, config: Config = {}) { super(ctx, 'workspaceController', { namespace: 'workspace' }) this.config = WorkspaceController.Config(config) From 5ff352b0be7d46c1ac5e8ef418ada4982cbb2c03 Mon Sep 17 00:00:00 2001 From: winewill Date: Sun, 20 Sep 2026 17:50:05 +0800 Subject: [PATCH 16/60] test(web): include current version without built-in browser --- .../expected/settings-chrome/dialog-no-browser.expected.md | 1 + apps/web/tests/settings-chrome.e2e.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md index 41d6462e11..1337d351d5 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md @@ -26,3 +26,4 @@ - switch "Developer tools" - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior - button "Queue" + - text: "Current version: {{version}}" diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 15d7bdeaa9..0c6e79308a 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -785,7 +785,7 @@ describe('web e2e: settings modal and General preferences', () => { const dialog = withoutBrowser.getByRole('dialog', { name: 'Settings' }) await dialog.getByRole('button', { name: 'Detailed', exact: true }).waitFor() expect(await dialog.getByText('Open chat links in', { exact: true }).count()).toBe(0) - const snapshot = await captureStableAria(withoutBrowser, '[role="dialog"]', fresh.workspaceCwd) + const snapshot = await captureStableAria(withoutBrowser, '[role="dialog"]', fresh.workspaceCwd, versionCapture) await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'dialog-no-browser.expected.md'), snapshot, MODE) expect(browserConsole.pageErrors).toEqual([]) expect(browserConsole.warnings).toEqual([]) From 021bd03b7078c009522796ff9b02901afbbeef05 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 20 Sep 2026 04:39:43 +0800 Subject: [PATCH 17/60] feat(gateway): let every Remote stream carry a client uplink Every Typert Remote stream accepts client-to-Host items on the same logical stream. A method declares the uplink item type in its return type, RemoteStream, and reads the items through this.ctx.invocation.uplink(); the invocation also carries the calling Peer. Client generated methods return a handle that iterates the downlink and exposes send/end/dispose for the uplink. --- .../2026-09-19-remote-duplex-stream.zh.md | 608 ++++++++++++++++++ docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 6 +- docs/api-gateway.zh.md | 6 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- docs/subsystems/typert.i18n.yaml | 4 +- docs/subsystems/typert.md | 23 +- docs/subsystems/typert.zh.md | 23 +- packages/api/README.i18n.yaml | 4 +- packages/api/README.md | 6 +- packages/api/README.zh.md | 6 +- packages/api/gateway/README.i18n.yaml | 4 +- packages/api/gateway/README.md | 9 +- packages/api/gateway/README.zh.md | 9 +- packages/api/gateway/src/client/index.ts | 165 ++++- .../api/gateway/src/client/stream-client.ts | 58 ++ packages/api/gateway/src/index.ts | 313 ++++++++- .../api/gateway/src/remote-error-codes.ts | 2 + packages/api/gateway/src/stream-protocol.ts | 14 +- packages/api/gateway/src/stream-server.ts | 252 +++++++- packages/api/gateway/src/types.ts | 16 +- .../api/gateway/tests/browser-credentials.ts | 27 + .../gateway/tests/echo-stream.host.spec.ts | 96 +++ .../gateway/tests/gateway-stream.host.spec.ts | 375 ++++++++++- .../api/gateway/tests/gateway.client.spec.ts | 215 ++++++- .../api/gateway/tests/gateway.host.spec.ts | 5 +- .../tests/stream-protocol.host.spec.ts | 19 + .../gateway/tests/stream-server.host.spec.ts | 368 +++++++++-- .../tests/sessions-service.client.spec.ts | 10 +- .../tests/transport.client.spec.ts | 14 +- packages/api/terminal-controller/package.json | 1 + .../tests/model.client.spec.ts | 43 +- .../tests/recovery.client.spec.ts | 17 +- .../tests/retention.client.spec.ts | 10 +- .../tests/model.client.spec.ts | 6 +- packages/api/workspace-files/package.json | 1 + .../tests/fake-remote.client.ts | 6 +- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- packages/client/connection/package.json | 2 + packages/client/connection/src/client/rpc.ts | 7 +- packages/client/connection/src/http-bridge.ts | 4 + packages/client/connection/src/index.ts | 20 +- packages/client/connection/src/peer-scope.ts | 90 +++ packages/client/connection/src/rpc-host.ts | 45 +- packages/client/connection/src/rpc.ts | 51 +- .../tests/client-apply.client.spec.ts | 10 +- .../client/connection/tsconfig.client.json | 3 + packages/client/connection/tsconfig.host.json | 9 +- .../webworker-runtime/src/client/client.ts | 63 +- .../webworker-runtime/src/client/index.ts | 9 +- .../webworker-runtime/src/index.ts | 4 +- .../webworker-runtime/src/transport/frames.ts | 17 + .../webworker-runtime/src/transport/tunnel.ts | 75 ++- .../webworker-runtime/src/worker-host.ts | 4 +- .../tests/transport/frames.spec.ts | 11 + .../tests/transport/tunnel-client.spec.ts | 68 ++ .../tests/transport/tunnel-server.spec.ts | 94 ++- .../src/client/api-catalog.ts | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 12 +- .../test-support/remote-mock/src/index.ts | 2 +- .../remote-mock/src/remote-mock.ts | 33 +- .../test-support/remote-mock/src/streams.ts | 42 +- .../remote-mock/tests/proxy.client.spec.ts | 7 +- .../remote-mock/tests/rpc.client.spec.ts | 19 + packages/typert/generator/src/analyzer.ts | 66 +- packages/typert/generator/src/emitter.ts | 32 +- packages/typert/generator/src/model.ts | 4 + .../fixtures/remote-model/tsconfig.base.json | 1 + .../remote-model/typert-protocol.d.ts | 20 + .../generator/tests/remote-model.spec.ts | 109 +++- packages/typert/protocol/package.json | 7 + packages/typert/protocol/src/client/index.ts | 45 ++ packages/typert/protocol/src/index.ts | 16 +- packages/typert/protocol/src/types.ts | 87 +++ .../typert/protocol/tests/protocol.spec.ts | 16 + packages/typert/protocol/tsconfig.json | 3 + packages/typert/registry/src/service.ts | 5 + packages/typert/registry/tests/typert.spec.ts | 16 + pnpm-lock.yaml | 16 + scripts/no-unknown-casts.baseline.json | 1 - tsconfig.base.json | 1 + 85 files changed, 3600 insertions(+), 313 deletions(-) create mode 100644 .agents/notes/proposed/architecture/2026-09-19-remote-duplex-stream.zh.md create mode 100644 packages/api/gateway/tests/echo-stream.host.spec.ts create mode 100644 packages/client/connection/src/peer-scope.ts create mode 100644 packages/typert/protocol/src/client/index.ts diff --git a/.agents/notes/proposed/architecture/2026-09-19-remote-duplex-stream.zh.md b/.agents/notes/proposed/architecture/2026-09-19-remote-duplex-stream.zh.md new file mode 100644 index 0000000000..89823f6f5d --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-19-remote-duplex-stream.zh.md @@ -0,0 +1,608 @@ +# Agent Note: Remote 双工流:一种流、上行通道与调用上下文 + +Status: proposed + +[English](2026-09-19-remote-duplex-stream.md) | 中文 + +## 问题 + +### 现状 + +`dsh-api-gateway` 在一条 WebSocket 上复用全部 Typert Remote 流,路径固定为 `/api/remote.mux`(`packages/api/gateway/src/stream-protocol.ts:6`)。一个 Host 方法用 `@Remote({ mode: 'stream' })` 修饰并返回 `Iterable` 或 `AsyncIterable`,就成为一条 Host 到 Client 的逻辑流;可选的末位 `signal: AbortSignal` 是唯一的保留参数,不进线路参数,由网关在解码后的业务参数之后追加(`packages/api/gateway/src/index.ts:613`)。客户端拿到的生成方法返回一个裸 `AsyncIterable`。 + +线路帧只有五种: + +| 方向 | 帧 | 定义 | +| --- | --- | --- | +| Client 到 Host | `{ type: 'open', streamId, endpoint, payload }` | `stream-protocol.ts:243-249` | +| Client 到 Host | `{ type: 'cancel', streamId }` | `stream-protocol.ts:250` | +| Host 到 Client | `{ type: 'item', streamId, value? }` | `stream-protocol.ts:261` | +| Host 到 Client | `{ type: 'error', streamId, error: { code, message, details } }` | `stream-protocol.ts:262` | +| Host 到 Client | `{ type: 'end', streamId }` | `stream-protocol.ts:263` | + +客户端能发的只有打开与取消。解析器 `parseRemoteStreamClientMessage`(`stream-protocol.ts:270-284`)对其他任何帧抛错,Host 连接收到后以 1008 关闭整个 socket(`stream-server.ts:121-125`)。结论:网关今天只支持"后端 iterable 发到前端"。需要前端向后端持续发数据的功能,各自绕路。 + +Host 方法也没有"这次调用是谁发起的"这个概念:`InvokeRemoteRequest = { namespace, method, args, signal }`(`packages/api/gateway/src/types.ts:10-19`)没有调用者槽位,`RemoteStreamOpener` 的签名是 `(endpoint, payload, signal)`(`stream-server.ts:13-17`),WebSocket 只在握手时校一次 cookie。 + +### 三套各自为政的上行机制 + +| 功能 | 上行方式 | 位置 | 代价 | +| --- | --- | --- | --- | +| Web 终端按键 | 每次 xterm `onData` 一个 unary RPC `terminal.write(agent, id, attachmentId, data)`,客户端用 promise 链串行化并自限字节预算 | `packages/api/terminal-controller/src/client/model.ts:193-205`;Host `src/index.ts:229-233` | 一次按键一次 HTTP 往返与鉴权;两条载体靠 `attachmentId` 手工关联;满了报 `inputFull` | +| 文件上传 | `Blob` 或 `ReadableStream` 走 Remote 层之外的原生 `POST /api/session/uploadFileBinary`,`duplex: 'half'` | `packages/client/file-upload/src/client/runtime.ts:198-211`;路由 `src/index.ts:72-80` | 不经 Typert 描述符,没有类型投影 | +| 审批与提问的回答 | Host 在 `$events` 流上下发 waterfall 帧,Client 用一个独立的 unary RPC `$events/result` 回答,靠 `clientId` 加 `eventId` 关联 | `packages/api/gateway/src/client/remote-events.ts:219-224`;Host `index.ts:357-369` | 第二套关联键;`index.ts:527-529` 记录了由此产生的竞态 | + +这三套机制解决的是同一个问题:一条已经打开的逻辑流,客户端想往里写。 + +### 具体代价 + +- **两条载体对齐**。终端要用 `attachmentId` 把 `follow` 流与 `write` RPC 关联起来,并用它决定谁拥有输入权。审批要用 `clientId` 把 `$events` 流与 `$events/result` RPC 关联起来。 +- **没有顺序保证**。unary RPC 走 HTTP,与 WebSocket 下行互不排序;终端只能在客户端串行化写入来自保顺序。 +- **每次上行单独鉴权与查找**。`write(agent, id, attachmentId, data)` 每次解析 `agent`、查终端、校验 attachment。 +- **没有 EOF**。客户端无法表达"我这边写完了",半关闭要再加一个 RPC。 +- **取消不覆盖两半**。取消下行流不会取消排队中的上行写;反之亦然。 +- **背压各写一套**。下行按 socket 写回调节拍拉迭代器(`stream-server.ts:155-199`);终端用有界 follower 让慢消费者显式失败(`terminal-controller/src/stream.ts:21-32`);上行靠客户端自限 `maxInputBytes`。 +- **文档已经过时**。`docs/api-gateway.md:160` 仍写着 Remote 只处理一请求一结果,这一句已经落后于 README 记录的 stream 模式。 + +### 与后台 job 的关系 + +后台 job 的进程形双工流面(已存档为草稿 PR,分支 `worktree/job-stdio-stream-pair-archive`)把 job 建成一对镜像的流面,浏览器要接它就需要一条既下行又上行的 Remote 流。它是本 Note 的动机之一,但不是本 Note 的范围:job 要不要一种专用的读写流,是 jobs 那条线自己的架构问题。本 Note 只交付传输层。 + +### 目标 + +1. 传输层只有一种流,天生双工:下行 `item` / `end` / `error`,上行 `item` / `end`,一个 `streamId`,一个代际。 +2. 上行类型与下行类型写在同一个位置:方法的返回类型。 +3. Host 方法通过调用上下文取上行;调用上下文同时携带调用者(Peer)。 +4. 客户端拿到的不再是裸 `AsyncIterable`,而是一个能读、能写、能关的句柄。 +5. 上层协议(snapshot、journal、任何业务自定义的流)不进传输层。 + +## 提案 + +### 一句话 + +所有 Remote 流都允许客户端上行。方法的返回类型 `RemoteStream` 同时声明下行项与上行项的类型;生成器从中生成两个 codec。Host 方法通过 `this.ctx.invocation.uplink()` 取上行迭代器,通过 `this.ctx.invocation.peer` 知道调用者。客户端生成方法返回 `RemoteStream` 句柄:`for await` 读下行,`send` / `end` 写上行,`dispose` 关。断线重开、游标续传、基线校验都是上层协议的事。 + +``` + Client Host + ───────────────────────────────── ─────────────────────────────────────────── + const s = remote.job.attach(req, signal) + │ open { streamId, endpoint, payload } ───────▶ opener(endpoint, payload, uplink, peer, control) + │ │ prepareInvocation + │ s.send(v1) → item { streamId, v1 } ───────▶ inbox.push(v1) │ invocation = { request, service, peer, signal, uplink() } + │ s.send(v2) → item { streamId, v2 } ───────▶ inbox.push(v2) │ receiverContext.extend({ invocation }).get(service) + │ s.end() → end { streamId } ───────▶ inbox.end() │ method(...args, signal) + │ ◀─────── item { streamId, o1 } │ for await (v of this.ctx.invocation.uplink()) … + │ for await (o of s) … ◀─────── item { streamId, o2 } │ yield o … + │ s.dispose() → cancel { streamId } ───────▶ control.abort() │ signal 中止;uplink.return() + │ ◀─────── end | error +``` + +### 术语 + +| 术语 | 含义 | +| --- | --- | +| 逻辑流 | 一个 `streamId` 标识的一次调用,跨越 `open` 到终止帧 | +| 代际 | 一条逻辑流的一次物理生命期。传输层只认一个代际;断线即失败,重开是上层的事 | +| 下行 | Host 到 Client 的 `item` 序列,以 `end` 或 `error` 终止 | +| 上行 | Client 到 Host 的 `item` 序列,以 `end` 终止(半关闭),或随 `cancel` 一起结束 | +| 半关闭 | 一个方向已结束而另一个方向仍开放 | +| 项 | 一个方向上的一个业务值;每项独立经 codec 校验 | +| inbox | Host 侧每逻辑流一个的有界上行队列 | +| Peer | 连接层被接纳的一方,一个 `PeerScope`;操作者是进程内默认 Peer | +| 调用上下文 | `RemoteInvocation`:本次调用的请求、接收服务、Peer、取消信号与上行入口 | + +### 类型签名 + +```ts +// @deepseek-ai/dsh-typert-protocol +/** + * 一条 Remote 流。Host 面:方法返回它,运行时就是 AsyncIterable。 + * Client 面:生成方法返回的句柄,见 RemoteStreamHandle。 + * In 是客户端可上行的项类型;缺省 never 表示该方法不读上行。 + */ +export type RemoteStream = AsyncIterable + +// Host +@Remote({ mode: 'stream' }) +async *attach(request: JobAttachRequest, signal: AbortSignal): RemoteStream { + const uplink = this.ctx.invocation.uplink() + void (async () => { for await (const frame of uplink) … })() + yield … +} + +// 生成的 Client 签名:参数不变,返回类型不变 +attach(request: JobAttachRequest, signal?: AbortSignal): RemoteStream + +// Client 使用 +const stream = remote.job.attach(req, signal) +for await (const frame of stream) … +stream.send(frame) // 类型为 JobInputFrame +stream.end() +stream.dispose() +``` + +`RemoteStream` 是别名而不是接口,是刻意的:将来若把传输层做成 Node 流形态,只换别名的定义与两端的适配,方法签名、描述符、上层协议都不动。 + +### Host 面 + +```ts +/** 本次 Remote 调用的上下文。方法通过 this.ctx.invocation 读它。 */ +export interface RemoteInvocation { + readonly request: { + readonly namespace: string + readonly method: string + readonly args: Readonly> + } + /** 接收服务的 Cordis service key。 */ + readonly service: string + /** 发起调用的 Peer。进程内载体与未接纳的调用是操作者。 */ + readonly peer: PeerScope + /** 载体取消:客户端 cancel、socket 关闭、上行失败。 */ + readonly signal: AbortSignal + /** + * 本次调用的上行项。只能取一次,第二次抛错。描述符带 uplink codec 时 + * 逐项解码为 In;不带时交付 unknown,只做 JSON 安全校验。 + * 客户端 end 后迭代结束;方法结束下行时网关调用它的 return(),未消费的项丢弃。 + * 泛型 In 只是调用方的类型断言,运行时按描述符解码,不做交叉校验。 + */ + uplink(): AsyncIterable +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** 本 Context 为之派生的 Remote 调用;非 Remote 调用派生的 Context 上为 undefined。 */ + readonly invocation: RemoteInvocation | undefined + } +} +``` + +规则: + +1. 任何 `mode: 'stream'` 方法都可以调用 `this.ctx.invocation.uplink()`;unary 方法也可以,但它的流在方法返回时结束,上行项只在方法运行期间可读。 +2. `uplink()` 未被调用时,上行项在 inbox 里攒着,直到超限(见背压)或流结束被丢弃。这是刻意的:不读上行的方法不用知道上行存在。 +3. `In` 缺省为 `never` 时描述符没有 uplink codec;客户端句柄的 `send` 类型为 `never`,编译期就不能发。运行时若仍有帧到达(手工构造的客户端),按 `unknown` 交付。 +4. `this.ctx.invocation` 在非 Remote 调用的 Context 上是 `undefined`;方法内读它时按 TS 的可选处理,或在方法第一行断言。 + +#### 调用上下文如何到达方法 + +沿用 `remote-client-access` 分支的机制,Cordis 源码确认成立: + +- `ctx.extend(meta)` 是 `Object.create(parent)` 加自有属性(`vendor/cordis/src/context.ts:99-107`),无注册、无 fiber、不需释放,每次调用派生一个是零成本的。 +- `ctx.get(service)` 返回 `createTraceable(ctx, service)` 代理(`vendor/cordis/src/reflect.ts:233-234`,`utils.ts:117-125`)。读 `ctx` 属性直接返回访问方的 Context(`utils.ts:175`);读方法时用 `createShadowMethod` 包一层,`apply` 时把 `this` 换成 shadow receiver,shadow 上的 `ctx` 是 `ctx.extend({ [symbols.shadow]: origin })`(`utils.ts:149-163`)。 +- 网关因此写 `receiverContext.extend({ invocation }).get(descriptor.service)` 取一个调用视图,`Reflect.apply(method, callReceiver, args)`;方法体里 `this.ctx.invocation` 沿原型链读到本次调用。async generator 体延后执行不受影响,因为 `this` 在调用时已绑定为 shadow 对象。 + +### PeerScope + +```ts +/** 一个 Peer 的不透明身份。 */ +export type PeerId = Branded<'PeerId'> + +/** 一个 Peer 在本 Host 上的会话:接纳它的一方打开与释放;ctx 拥有连接期注册。 */ +export interface PeerScope { + readonly id: PeerId + readonly ctx: Context + dispose(): Promise +} +``` + +`PeerRegistry` 放在 `@deepseek-ai/dsh-client-connection`(`src/peer-scope.ts`): + +| 成员 | 语义 | +| --- | --- | +| `operator: PeerScope` | 进程内默认 Peer,服务 apply 时打开 | +| `open(): PeerScope` | 为一次连接打开一个 scope,`ctx` 由 `createScope(connectionCtx, peer)` 派生 | +| `bind(carrier, peer)` / `of(carrier)` | 用 WeakMap 把 WebSocket 或 Fetch `Request` 绑到 Peer | +| `dispose()` | scope 回收;`stream-server` 在 `peer.ctx.effect` 里登记 socket 关闭,scope 释放即断连 | + +网关 `handleUpgrade(req, socket, head, peer)` 为该 socket 绑一个固定 Peer 的 opener;`InvokeRemoteRequest.peer?` 与 `RemoteStreamOpener` 的 `peer` 形参缺席时按操作者作答,现有进程内调用方零改动。 + +不采用的部分:`@Access`、`AccessDeclaration`、`AccessLevel`、`AccessTarget`、`vouch` / `vouched`、`remote/invoke` 与 `remote/deliver` 事件、`mayDeliver`、`requireVoucher`、`gateway/access-denied`。Peer 是谁、能做什么,由业务插件通过 `peer.ctx` 或按 `PeerScope` 键的注册表自行附加,与本 Note 无关。 + +### Client 面 + +```ts +/** Client 面上生成方法返回的句柄;RemoteStream 在 Client 面解析为它。 */ +export interface RemoteStreamHandle extends AsyncIterable { + /** 发送一个上行项。流已终止或已 end 时抛错。 */ + send(item: In): void + /** 上行半关闭:发 end 帧。幂等。 */ + end(): void + /** 取消整条逻辑流:发 cancel 帧(未收到终止帧时),下行迭代器安静结束。 */ + dispose(): void +} +``` + +- 句柄代表一个代际。载体丢失时 `for await` 以 `RemoteStreamCarrierError` 失败,句柄到此结束;要不要重开、带什么参数重开,是上层协议的事。 +- `send` 是同步的:浏览器 `WebSocket.send` 没有写回调,发送节流没有意义;Host 侧的 inbox 上限是唯一的背压点。 +- 消费者提前 `break` 出 `for await` 等价于 `dispose()`。 +- `$stream` 监督器、`RemoteSnapshotStream`、`RemoteJournalStream` 是上层工具,本 Note 不改它们;它们的 `open` 工厂将来接的是句柄而不是裸 `AsyncIterable`,属于平滑替换。 + +### 线路帧 + +```ts +export type RemoteStreamClientMessage = + | { readonly type: 'open'; readonly streamId: string; readonly endpoint: string; readonly payload: unknown } + | { readonly type: 'item'; readonly streamId: string; readonly value?: unknown } // 新增:上行项 + | { readonly type: 'end'; readonly streamId: string } // 新增:上行半关闭 + | { readonly type: 'cancel'; readonly streamId: string } + +/** Host 发出的帧不变。 */ +export type RemoteStreamServerMessage = + | { readonly type: 'item'; readonly streamId: string; readonly value?: unknown } + | { readonly type: 'error'; readonly streamId: string; readonly error: RemoteStreamFailure } + | { readonly type: 'end'; readonly streamId: string } +``` + +解析规则:`item` 精确键 `type` `streamId` 加可选 `value`,`value` 须为无损 JSON 值(`isRemoteJsonValue`);`end` 精确键 `type` `streamId`。其余形状仍是协议违规,socket 以 1008 关闭。不引入二进制帧。 + +### 逻辑流状态机 + +#### Host 侧 + +``` + open 帧 + (无) ─────────────────────▶ opening ──── opener 解析完成 ────▶ running + │ │ + │ item 帧: inbox.push │ item 帧: inbox.push + │ end 帧: inbox.end │ end 帧: inbox.end(上行半关闭) + │ │ 方法 yield: 发 item 帧 + │ cancel 帧 / socket 关闭 ────────▶│ cancel 帧 / socket 关闭: control.abort + ▼ ▼ + aborted ◀───────────────────────── finished(发 end 或 error 帧) +``` + +`opening` 期间到达的 `item` 帧进入 inbox:`receive()` 是同步的,`open` 帧处理时就创建 `ActiveStream` 与 inbox,而 opener 是异步的。客户端在 `open` 之后立刻发的项不会丢。 + +#### Client 侧 + +``` + 调用生成方法 ──▶ 等 socket ──▶ 发 open ──▶ 句柄可用 + │ send(v): 发 item;end(): 发 end + │ 下行:inbox.next() → yield;end → 结束;error → 抛 + │ 下行先终止:send/end 抛错 + │ dispose 或调用方 signal 中止:发 cancel(未收到终止帧时) +``` + +### 半关闭与结束语义 + +| 上行 | 下行 | 结果 | +| --- | --- | --- | +| 客户端 `end` | 仍开放 | Host 的 `uplink()` 迭代结束;方法继续产出。这是 stdin EOF 的形状 | +| 仍开放 | Host 方法结束,发 `end` | 客户端句柄终止,之后 `send` / `end` 抛错;Host 调用 `uplink` 迭代器的 `return()`,未消费项丢弃 | +| 仍开放 | Host 方法抛错,发 `error` | 同上;客户端下行以 `RemoteError` 失败 | +| 客户端 `dispose` 或 signal 中止 | 任意 | 发 `cancel`;Host `control.abort()`,`cancellableStream` 调方法迭代器的 `return()`,再调 `uplink` 的 `return()`;不发终止帧 | +| socket 关闭 | 任意 | Host 中止全部流并等待 `done`;客户端每条流以 `RemoteStreamCarrierError` 失败 | +| `end` 之后又收到 `item` | 任意 | 该流以 `gateway/protocol` 错误帧失败并中止;socket 不关闭 | +| 未知 `streamId` 的 `item` / `end` | 任意 | 协议违规,socket 以 1008 关闭,与重复 id 同级 | + +### 顺序保证 + +同一条 socket 上的帧全序到达:一条逻辑流的 `open`、`item`…、`end` 按发送顺序到达 Host;上行项进入 `uplink()` 的顺序就是 `send` 的顺序。上行与下行之间没有跨方向顺序保证;需要请求应答配对的协议自带序号。 + +### 背压 + +WebSocket 没有 HTTP/2 那样的每流窗口,一个连接一条串行写链,慢流会拖住同连接的其他流。下行今天已经如此,本 Note 不改。上行: + +**有界 inbox 加整流失败。** Host 每条逻辑流的 inbox 按帧的 UTF-8 字节计量,上限为网关配置 `streamInboxBytes`(默认 262144),对所有流生效。超限时整条逻辑流以 `gateway/uplink-overflow` 失败:下行一起断,向客户端发 `error` 帧,`uplink()` 迭代器以同一错误结束。数据不丢弃;一个方法不读上行而客户端持续发,是用法错误,由失败显式暴露。 + +credit 帧留待需要持续大流量上行的消费者出现时再加;inbox 上限就是初始授信,加法是增量。 + +### 安全与校验 + +| 项 | 规则 | +| --- | --- | +| 鉴权 | 与今天相同:升级时 `connection.requestRejection(req)`;打开时描述符与查找参数校验。上行项属于一条已鉴权的流,不重复鉴权 | +| Peer | 升级时接纳为一个 `PeerScope`,该 socket 上所有流的 `invocation.peer` 都是它 | +| 项校验 | 有 uplink codec 时逐项严格解码,失败即整条流以既有的 `gateway/input-invalid` 失败(details `{ endpoint, field: 'uplink' }`);无 codec 时只做 JSON 安全校验 | +| 帧大小 | 单帧沿用 `ws` 的 `maxPayload`;inbox 上限限制累计量 | + +### 进程内与 worker 载体 + +| 载体 | 改为 | +| --- | --- | +| `ClientConnectionRpc.open`(`packages/client/connection/src/rpc.ts:245-250`)与 `RpcStreamOpen`(`client/rpc.ts:21-25`) | 追加可选 `uplink?: AsyncIterable`;进程内没有帧也没有 inbox,调用方迭代器直接作为 `uplink()` 的源,背压就是迭代器本身的节拍 | +| worker 隧道(`packages/experimental/webworker-runtime`) | 页面新增 `stream-uplink-item` / `stream-uplink-end` 帧;`serveStream` 把它们组装成 `uplink` 交给 `seams.openStream` | +| `remote-mock`(`packages/test-support/remote-mock`) | `StreamHandle.uplink: AsyncIterable`;`rpc.open` 透传 | + +进程内载体的 `peer` 缺席即操作者。 + +### 与 `$events` 的关系 + +`$events` 是一条保留端点的流;有了上行帧,waterfall 的回答可以成为同一 `streamId` 上的上行项,`clientId` 因 `streamId` 已标识代际而多余,`$events/result` 与 `index.ts:527-529` 的竞态一起消失。不在本 Note 范围,列在迁移的最后。 + +## 类型与线路定义 + +### typert protocol(`packages/typert/protocol/src`) + +```ts +// types.ts +export type RemoteStream = AsyncIterable +export type PeerId = Branded<'PeerId'> +export interface PeerScope { readonly id: PeerId; readonly ctx: Context; dispose(): Promise } +export interface RemoteInvocation { … } // 见 Host 面 +declare module '@deepseek-ai/cordis' { interface Context { readonly invocation: RemoteInvocation | undefined } } + +export interface InvocationDescriptor { + // 既有字段不变;mode 仍只有 'stream' + readonly mode?: 'stream' + /** 上行项 codec,从返回类型 RemoteStream 的 In 生成;In 为 never 时缺席。 */ + readonly uplink?: { readonly codec: TypertCodec } + readonly cancellation?: { readonly parameter: 'signal' } + readonly result: TypertCodec +} +``` + +`Remote` 修饰器、`RemoteMethodOptions`、`RemoteMethodMarker` 恢复为只认 `mode: 'stream'`。 + +### typert generator(`packages/typert/generator/src`) + +- `model.ts`:`InvocationModel.uplink?: { boundary: RemoteBoundaryModel }`。 +- `analyzer.ts` `remoteResultType`(现 `~1402`):对 `mode: 'stream'`,接受的返回类型包装器为 `Iterable`、`AsyncIterable`、`RemoteStream`。识别 `RemoteStream` 的方式与识别标准库 `AsyncIterable` 相同:符号名加声明所在文件(`@deepseek-ai/dsh-typert-protocol` 的 `types.ts`)。第一个类型参数是下行项,第二个存在且不是 `never` 时生成 `uplink` boundary,键名 `${endpoint}:uplink`。 +- `emitter.ts`:描述符字面量输出 `uplink: { codec }`;生成的 Client 签名返回类型原样渲染为 `RemoteStream`(Client 面解析为句柄类型,见下)。 +- 参数循环不再识别任何名为 `uplink` 的参数。 + +### Client 面的 `RemoteStream` 解析 + +`RemoteStream` 在 Host 面是 `AsyncIterable`,在 Client 面是 `RemoteStreamHandle`。两个编译面通过 protocol 包按面区分的入口各自导出同名别名;生成代码只写 `RemoteStream`,由所在编译面决定它是什么。这与 protocol 已有的 Host / Client 分面导出一致。 + +### 网关 Host(`packages/api/gateway/src`) + +```ts +// types.ts +export interface InvokeRemoteRequest { + readonly namespace: string + readonly method: string + readonly args: Readonly> + /** 上行项;缺席等价于立即结束的迭代器。 */ + readonly uplink?: AsyncIterable + /** 发起调用的 Peer;缺席即操作者。 */ + readonly peer?: PeerScope + readonly signal?: AbortSignal +} + +export interface TypertGatewayWireStream { + open: (endpoint: string, payload: unknown, uplink: AsyncIterable, peer: PeerScope | undefined, signal: AbortSignal) => Promise> + failure: (error: unknown) => RemoteStreamFailure +} + +// stream-server.ts +export type RemoteStreamOpener = ( + endpoint: string, + payload: unknown, + uplink: AsyncIterable, + peer: PeerScope, + control: AbortController, +) => Promise> + +// index.ts Config 新增 +/** 每条逻辑流的上行 inbox 上限,按帧的 UTF-8 字节计(默认 262144)。 */ +readonly streamInboxBytes?: number +``` + +`prepareInvocation`: + +```ts +const args = await Promise.all(descriptor.parameters.map(parameter => this.resolveParameter(parameter, request.args, endpoint))) +const signal = request.signal ?? NEVER_ABORTED_SIGNAL +const invocation = new GatewayInvocation( + { namespace: request.namespace, method: request.method, args: request.args }, + descriptor.service, + request.peer ?? this.operatorPeer(), + signal, + new UplinkDecoder(request.uplink ?? EMPTY_ASYNC_ITERABLE, descriptor.uplink?.codec, endpoint, control), +) +const callReceiver = receiverContext.extend({ invocation }).get(descriptor.service) as object +if (descriptor.cancellation !== undefined) args.push(signal) +``` + +`GatewayInvocation.uplink()` 第一次调用返回解码器,第二次抛错。`UplinkDecoder` 是手写迭代器:有 codec 时逐项 `decode(codec, value, endpoint, 'uplink')`,失败即 `control.abort(failure)` 并抛出;无 codec 时只做 `isRemoteJsonValue` 校验。`cancellableStream` 的 `finally` 在方法迭代器 `return()` 之后调用解码器的 `return()`。unary 与 stream 的模式分支恢复为今天的两分支。SRC 回退:描述符无 `uplink` codec,`uplink()` 交付 `unknown`。 + +### 网关 Host mux(`stream-server.ts`) + +```ts +interface ActiveStream { readonly control: AbortController; readonly inbox: UplinkInbox; done: Promise } + +/** 有界上行队列;作为 uplink() 的源被迭代。 */ +class UplinkInbox implements AsyncIterable, AsyncIterator { + constructor(maxBytes: number, onOverflow: (error: RemoteError) => void) + push(value: unknown, frameBytes: number): void // ended 后 push 使流以 gateway/protocol 失败;超限调用 onOverflow + end(): void // 幂等 + fail(error: unknown): void // 取消或载体关闭时让迭代器以错误结束 + next() / return() // 手写:next 与失败竞速,return 立即释放 +} +``` + +`receive(text)` 分派: + +``` +open : 已存在 → 抛错(socket 1008);否则创建 { control, inbox, done },启动 pump +item : 不存在 → 抛错(socket 1008);否则 inbox.push(value, bytes) +end : 不存在 → 抛错(socket 1008);否则 inbox.end() +cancel : 不存在 → 忽略;否则 control.abort(new Error('Remote stream cancelled')) +``` + +`pump` 把 inbox 与该 socket 的 Peer 传给 opener;`finally` 里 `inbox.fail(...)`。以 `RemoteError` 为 reason 的中止由 pump 作为 `error` 帧发出(网关或 mux 发起的失败);以普通 `Error` 为 reason 的中止(cancel、socket 关闭)不发终止帧。 + +### 网关 Client(`packages/api/gateway/src/client`) + +- `prepareInvocation` 不再剥离任何上行参数;位置参数仍是业务参数加可选 `signal`。 +- `invokeStream` 返回 `RemoteStreamHandle`:内部持有一个上行队列,`send` 入队并由泵发 `item` 帧,`end` 发 `end` 帧,`dispose` 中止代际 signal。下行仍是 `openRemoteStream` 返回的迭代器。 +- `RemoteStreamMuxClient.open(endpoint, payload, signal, uplink?)`:在发出 `open` 帧后启动上行泵,逐项发 `item`,源结束发 `end`;下行终止或 signal 中止时停泵并调用源的 `return()`(不等待,避免键盘类生成器卡死);源抛错时 `inbox.fail(error)`,下行以该错误失败,`finally` 按既有规则发 `cancel`。泵检查 `this.socket === socket`,旧代际不往新 socket 上发帧。 +- 进程内载体:`connection.rpc.open(channel, endpoint, payload, signal, uplink)`。 + +## 实现要点 + +按文件列出。实现者按此顺序推进,每步都应能单独 `typecheck`。 + +| 步 | 文件 | 改动 | +| --- | --- | --- | +| 1 | `packages/typert/protocol/src/types.ts` | `RemoteStream` 别名、`PeerId`、`PeerScope`、`RemoteInvocation`、`ctx.invocation` 声明合并、`InvocationDescriptor.uplink`;`mode` 仍只有 `'stream'` | +| 2 | `packages/typert/protocol/src/index.ts` | 修饰器恢复只认 `mode: 'stream'`;导出新类型;Client 面入口导出 `RemoteStreamHandle` 并把 `RemoteStream` 解析为它 | +| 3 | `packages/typert/registry/src/service.ts` | 加载校验:`uplink.codec` 为有效的严格 codec | +| 4 | `packages/typert/generator/src/model.ts` | `InvocationModel.uplink?: { boundary }` | +| 5 | `packages/typert/generator/src/analyzer.ts` | `remoteResultType` 识别 `RemoteStream` 并生成 `uplink` boundary;参数循环只认业务参数与末位 `signal` | +| 6 | `packages/typert/generator/src/emitter.ts` | 输出 `uplink: { codec }`;生成签名的参数恢复;返回类型渲染 `RemoteStream` | +| 7 | `packages/client/connection/src/peer-scope.ts`(新,从 `remote-client-access` 分支照搬) | `PeerRegistry`:`operator`、`open`、`bind` / `of`、`dispose`;`rpc.ts` 暴露 `peers`;`/api` 与升级路由绑定 Peer | +| 8 | `packages/api/gateway/src/stream-protocol.ts` | `item` / `end` 帧(已有) | +| 9 | `packages/api/gateway/src/stream-server.ts` | opener 加 `peer`;`handleUpgrade(req, socket, head, peer)`;`UplinkInbox`(已有,改名配置) | +| 10 | `packages/api/gateway/src/types.ts` | `InvokeRemoteRequest.uplink` / `peer`;`wireStream.open` 签名 | +| 11 | `packages/api/gateway/src/index.ts` | `Config.streamInboxBytes`;`GatewayInvocation`;`extend({ invocation })`;`UplinkDecoder` 按描述符 codec 可选;`operatorPeer()`;unary 与 stream 两个模式分支 | +| 12 | `packages/api/gateway/src/client/index.ts`、`stream-client.ts` | `invokeStream` 返回句柄;泵(已有)改由句柄的队列驱动 | +| 13 | `packages/client/connection/src/rpc.ts`、`client/rpc.ts` | `open` 加可选 `uplink`(已有) | +| 14 | `packages/experimental/webworker-runtime` | 两种上行帧与 `serveStream` 组装(已有) | +| 15 | `packages/test-support/remote-mock` | `StreamHandle.uplink`(已有) | +| 16 | 文档 | `docs/api-gateway.md:5, 160`;`packages/api/gateway/README.md:12, 35, 46, 54`、错误码与 Known Limitations;`packages/api/README.md:12, 30, 38`;`packages/client/connection/README.md:30`;`docs/subsystems/typert.md:88-89, 113`;`protocol/src/index.ts:96, 103` 与 `types.ts:269, 294` 的 JSDoc;Cordis 目录再生成 | + +## 关键流程时序 + +### 打开、上行、半关闭、下行结束 + +``` +Client 调用方 mux client mux server gateway Host 方法 + │ attach(req) │ │ │ │ + │───────────────────▶│ open { id, ep, payload } ─────────────────▶│ 创建 inbox │ + │ s.send(v1) │ item { id, v1 } ──────────────────────────▶│ inbox.push(v1) │ + │ │ │ │ prepareInvocation │ + │ │ │ │ extend({ invocation }) │ + │ │ │ │───── method(req, signal) ─────────▶│ + │ │ │ │ │ uplink() → v1 + │ s.send(v2) │ item { id, v2 } ──────────────────────────▶│ inbox.push(v2) │ → v2 + │ s.end() │ end { id } ───────────────────────────────▶│ inbox.end() │ 迭代结束 + │◀── yield o1 ───────│◀────────────────────── item { id, o1 } ────│◀── yield o1 ────────│ + │◀── 结束 ───────────│◀────────────────────── end { id } ─────────│◀── 方法返回 ─────────│ gateway 调 uplink.return() +``` + +### 客户端 dispose + +``` +Client 调用方 mux client mux server gateway Host 方法 + │ s.dispose() │ │ │ │ + │───────────────────▶│ 停泵;源 return()(不等待) │ │ + │◀── 迭代器结束 ─────│ cancel { id } ────────────────────────────▶│ control.abort() │ + │ │ │ │ cancellableStream: iterator.return() ──▶│ finally 清理 + │ │ │ │ uplink.return() │ + │ │ │ 不发终止帧 │ │ +``` + +### inbox 超限 + +``` +Client 调用方 mux client mux server gateway / Host 方法 + │ s.send(vN) │ item { id, vN } ─────▶│ inbox.push: bytes > streamInboxBytes │ + │ │ │ onOverflow → control.abort(RemoteError uplink-overflow) + │ │ │ │ uplink() 以该错误结束;方法迭代器 return() + │◀── throw ──────────│◀── error { id, uplink-overflow } ─────────│ pump 因 reason 是 RemoteError 发 error 帧 +``` + +## 边界矩阵 + +| 事件 | opening | running | 上行已 end | 下行已终止 | +| --- | --- | --- | --- | --- | +| 收到 `item` | 入 inbox 缓冲 | 入 inbox | 流以 `gateway/protocol` 失败 | 流已不在表中 → socket 1008 | +| 收到 `end` | inbox.end | inbox.end | 幂等 | 同上 | +| 收到 `cancel` | abort | abort | abort | 忽略 | +| inbox 超限 | 流以 `uplink-overflow` 失败 | 同 | 不可能 | 不可能 | +| 解码失败 | 不可能 | 流以 `input-invalid` 失败 | 同 | 不可能 | +| Host 方法结束 | 不可能 | 发 `end`;`uplink.return()` | 发 `end` | 已发 | +| Host 方法抛错 | 发 `error` | 发 `error`;`uplink.return()` | 发 `error` | 已发 | +| socket 关闭 | abort,等 done | 同 | 同 | 无事 | +| Client `send` | 入队,open 后发 | 发 | 抛错 | 抛错 | +| Client `end` | 记录,open 后发 | 发 | 幂等 | 忽略 | +| Client 侧下行先结束 | 不可能 | 停泵;源 `return()`;不发 `end` | 无事 | 无事 | + +## 错误码 + +| 错误码 | 何时 | +| --- | --- | +| `gateway/input-invalid` | 既有:参数 codec 解码失败;新增:上行项解码失败 | +| `gateway/uplink-overflow` | 新增:inbox 超过 `streamInboxBytes` | +| `gateway/protocol` | 新增:`end` 之后的 `item` | +| `gateway/cancelled` | 既有:`signal` 中止 | + +新码登记在 `packages/api/gateway/src/remote-error-codes.ts` 与 README 的错误码一节。 + +## 最小 echo 方法 + +验收用,放在网关测试支持里: + +```ts +class EchoService extends TypertRemoteService { + constructor(ctx: Context) { super(ctx, 'echo', { namespace: 'echo' }) } + + @Remote({ mode: 'stream' }) + async *echo(prefix: string, signal: AbortSignal): RemoteStream { + for await (const item of this.ctx.invocation!.uplink()) { + signal.throwIfAborted() + yield `${prefix}${item}` + } + } +} + +// 客户端 +const stream = remote.echo.echo('> ') +stream.send('a'); stream.send('b'); stream.end() +const replies: string[] = [] +for await (const reply of stream) replies.push(reply) // ['> a', '> b'] +``` + +## 迁移步骤 + +| 阶段 | 内容 | 范围 | +| --- | --- | --- | +| A | 实现要点表第 1 到 15 步 | 本 Note | +| B | 第 16 步文档;typert 与网关的测试;echo 端到端 | 合并前 | +| C | `$stream` / snapshot / journal 的 `open` 工厂接句柄 | 后续 | +| D | Web 终端:一条 `attach` 流取代 `follow` 加 `write` 加 `resize` | 独立 PR | +| E | 双工 `$events`:回答成为上行项 | 独立 PR | +| F | credit 帧 | 按需 | + +## 考虑过的替代方案 + +**独立的 `mode: 'duplex'` 加一个与 `signal` 同级的保留参数 `uplink: AsyncIterable`。** 每一处都是特殊逻辑:analyzer 要认第二个保留参数及其位置规则,生成签名多一个参数,加载期要校验 `duplex ⟺ uplink`,mux 不知道方法模式所以网关要替 `'stream'` 方法拒绝上行帧。双工是传输层流的固有性质,不是方法的一种模式:载体本来就有 `streamId`、`open`、`cancel`,上行只是再加两种帧,任何流都能收,不用就不发。 + +**用装饰器选项声明上行类型,`@Remote({ mode: 'stream', uplink: 'JobInputFrame' })`。** 字符串引用类型名不受编译器约束,重命名即断。返回类型别名把两个类型写在同一个编译器可见的位置。 + +**运行时传 schema:`uplink(schema)`。** 不经生成器,客户端失去类型投影,与"每项严格 codec"的体系分裂。codec 的来源仍是生成器,方法里的泛型只是断言。 + +**analyzer 交叉校验 `uplink()` 手填的泛型与返回类型一致。** 业务特化的校验,写错后果由调用方承担,与仓库其他处手填断言一致;不加。 + +**传输层句柄带重开、游标、`reopen` 钩子。** 断线重开是上层协议的事:job 的续传是 `opened` 帧带 `from`、客户端记住 `next` 再调一次;snapshot 与 journal 各有自己的锚点与分类。传输层只认一个代际。 + +**客户端 `send` 放在生成方法返回的 `AsyncIterable` 上。** `AsyncIterable` 上挂方法不常规;句柄类型 `RemoteStreamHandle` 显式扩展它。 + +**照搬 Access 机制。** 访问控制是业务语义,`vouch` 与 `remote/invoke` waterfall 为它服务;本 Note 只需要"这条流是谁的",`PeerScope` 就够。 + +**继续用 unary RPC 做上行。** 每次上行一次 HTTP 往返、一次鉴权、一次查找;两条载体没有顺序关系,只能靠客户端串行化;没有 EOF;取消不覆盖两半;每个功能各造一套关联键。 + +**原生 HTTP 请求体流。** 一次性请求体,与下行流没有关联,不经 Typert 描述符,浏览器对全双工 fetch 的支持不可依赖。 + +**每条双工流一个独立 WebSocket。** 失去与现有下行流共享的鉴权、心跳与多路复用,每个功能要自己管一条 socket 的生命周期。 + +**WebTransport 或 HTTP/2 双向流。** 有每流流控,但浏览器与 Node 两侧的支持、代理穿透与 Electron 载体都不成熟;在现有 mux 上加两种帧比换传输层便宜几个量级。 + +## 验收条件 + +1. `RemoteStream` 在 Host 面是 `AsyncIterable`,在 Client 面是 `RemoteStreamHandle`;一个方法从 `AsyncIterable` 改为 `RemoteStream` 不改变任何生成产物。 +2. analyzer 对 `RemoteStream` 生成 `uplink` codec;`In` 缺省或为 `never` 时描述符无 `uplink`。 +3. 网关 Host 对每次调用派生 `extend({ invocation })` 视图;方法体内 `this.ctx.invocation.peer` 是接纳该 socket 的 Peer,进程内调用是操作者。 +4. `uplink()` 只能取一次;有 codec 时逐项解码并在失败时以 `gateway/input-invalid` 中止流;无 codec 时交付 `unknown`。 +5. mux 接受 `item` / `end`,opening 期间缓冲,`end` 后的 `item` 使流以 `gateway/protocol` 失败而不关 socket,未知 `streamId` 关 socket,超限以 `gateway/uplink-overflow` 失败并发 `error` 帧。 +6. 客户端句柄:`send` 在 open 前入队、open 后发帧,终止后抛错;`end` 幂等;`dispose` 发 `cancel`;下行先终止时停泵并调用源的 `return()`。 +7. 进程内载体、worker 隧道、remote-mock 都能打开带上行的流并把项交付到 `uplink()`。 +8. echo 方法在 WebSocket 与进程内载体上行为一致。 +9. 既有 `'stream'` 与 unary 方法的行为与线路帧不变;既有网关测试不改期望值即通过。 +10. `pnpm run typecheck`、`test:coverage`、`lint`、`duplication`、`build`、`hygiene`、`doc-sync` 通过。 + +## 风险 + +- **`RemoteStream` 分面解析**依赖 protocol 包按 Host / Client 面导出同名别名;若某个编译面同时引用两侧类型,会看到两个不同的 `RemoteStream`。分面导出是仓库既有做法,但这是第一个在两面语义不同的类型。 +- **`this.ctx.invocation` 在非 Remote 调用下是 `undefined`**,方法体读它要处理可选;一个只被进程内直接调用(不经网关)的服务方法读到 `undefined` 是正确行为。 +- **PeerRegistry 从另一分支照搬**,其升级路由与 `/api` 路由的绑定改动落在 `dsh-client-connection`,是本 Note 唯一触及连接层的地方;照搬时剔除 access 相关成员。 +- **inbox 上限对所有流生效**,一个不读上行的方法收到大量上行帧会整流失败;这是刻意的显式失败,写进 README。 +- **本 Note 只有中文版**,配对门禁在补齐英文版之前失败;合并前补。 + +## 未决问题 + +1. `streamInboxBytes` 的默认值:262144 对键盘与 stdin 远超需要;若第一个消费者是终端粘贴,可能与 `terminal-controller` 的 `maxInputBytes` 对齐。 +2. 是否需要 Host 发起的半关闭帧(Host 不再读上行但继续写下行)。没有消费者,不加。 +3. unary 方法是否应禁止 `uplink()`:它能读到方法运行期间到达的项,语义成立但用处存疑;先允许,README 注明。 diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 673807013b..c0bdc7fd9b 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.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/api-gateway.md -api-gateway.md: aa8339219a563901d5d132f4f401325ca30a3d53 -api-gateway.zh.md: 99b2d9866d38e520880e5d13115c2e6370fca474 +api-gateway.md: 8f2ca725f47dce089753168deefa7b3f28e4e52f +api-gateway.zh.md: 27e04d2d89f0d8c583d94bcf079980a5b0f8cb52 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index aa8339219a..8f2ca725f4 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -2,7 +2,7 @@ English | [中文](api-gateway.zh.md) -This is the current-state reference for the Typert API Gateway. It describes how business services declare unary Remote methods, how the build generates Host and Client contracts, and how calls reuse the Connection RPC and `/api` route. Session events, incremental data, and other streaming protocols are outside this document's scope; they may use the same Connection but do not use Remote method descriptors. +This is the current-state reference for the Typert API Gateway. It describes how business services declare unary, stream, and duplex Remote methods, how the build generates Host and Client contracts, and how calls reuse the Connection RPC and `/api` route. Session events, incremental data, and other streaming protocols are outside this document's scope; they may use the same Connection but do not use Remote method descriptors. ## Programming model @@ -55,6 +55,8 @@ export class GoalService extends TypertRemoteService { Remote methods may return a value synchronously or return a Promise. For cooperative cancellation, the final parameter in the Host signature must be `signal: AbortSignal` using the global type; it is recorded in the descriptor instead of entering `args`, while the generated Client method accepts an optional final `AbortSignal`. +`@Remote({ mode: 'stream' })` marks a method that returns `Iterable` or `AsyncIterable`: the Gateway validates and delivers each yielded item over its multiplexed `/api/remote.mux` WebSocket or an in-process carrier, and the generated Client method returns an `AsyncIterable`. `@Remote({ mode: 'duplex' })` adds the Client-to-Host direction: the Host signature declares the reserved `uplink: AsyncIterable` parameter immediately before the optional `signal`, the generated Client method accepts an `AsyncIterable` in the same position, and the Gateway validates each item the Client sends with the generated `In` codec before the Host method reads it. Like `signal`, `uplink` is recorded in the descriptor instead of entering `args`; the [Gateway README](../packages/api/gateway/README.md) owns the frame, half-close, cancellation, and inbox-bound contracts. + The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. Direct and scoped calls appear under `ctx.remote.` and `agentCtx.remote.`. Each namespace is a traced Cordis child Service registered as `remote.`; the Client assembly mounts contributions through `ctx.remote.$mount()`, and the namespace unloads after its last method is withdrawn. Dependency declarations belong to the actual caller: only a business package that reads `ctx.remote.` or `agentCtx.remote.` declares both `remote` and `remote.` in its own `inject`; assemblies that only mount contributions and higher-level runtimes that do not call that namespace do not declare the namespace dependency on the business package's behalf. When an `@Remote` method has exactly one lookup parameter and a same-named `TypertContextMap` uses the same wire identity, the generated scoped signature omits that identity parameter. `@RemoteScope` generates only the scoped invocation interface. ```ts ignore-check @@ -156,7 +158,7 @@ The running Client watcher consumes these generated files when it rebundles. If ## Boundaries -Remote handles only unary method calls with one request and one result. Session event streams, pagination, incremental reduce, projection, and entity substreams require a separate data protocol and registration model; even when they reuse the Connection, they must not masquerade as Remote methods or enter invocation descriptors. +Remote handles unary method calls with one request and one result, stream methods whose items flow Host → Client, and duplex methods whose Client items reach the running Host method through the reserved `uplink` parameter. Session event streams, pagination, incremental reduce, projection, and entity substreams still require a separate data protocol and registration model; even when they reuse the Connection, they must not masquerade as Remote methods or enter invocation descriptors. The API layers are organized as `remotes → gateway → connection → webserver`. The BFF and Typert RPC layers live under `packages/api`; Connection and WebServer live at `packages/client/connection` and `packages/host/webserver`. A feature that needs a streamed or browser-native response registers an exact Connection Fetch route instead of defining a Remote method. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 99b2d9866d..27e04d2d89 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -2,7 +2,7 @@ [English](api-gateway.md) | 中文 -本文是 Typert API Gateway 的当前状态参考。它描述业务服务如何声明一元 Remote 方法、构建如何生成 Host 与 Client 约定,以及调用如何复用 Connection 的 RPC 与 `/api` 路由。会话事件、增量数据和其他流协议不属于本文范围;它们可以使用同一个 Connection,但不使用 Remote 方法描述符。 +本文是 Typert API Gateway 的当前状态参考。它描述业务服务如何声明一元、流式与双工 Remote 方法、构建如何生成 Host 与 Client 约定,以及调用如何复用 Connection 的 RPC 与 `/api` 路由。会话事件、增量数据和其他流协议不属于本文范围;它们可以使用同一个 Connection,但不使用 Remote 方法描述符。 ## 编程模型 @@ -55,6 +55,8 @@ export class GoalService extends TypertRemoteService { Remote 方法可以同步返回或返回 Promise。若需要协作式取消,Host 签名的最后一个参数必须是全局类型的 `signal: AbortSignal`;它记录在描述符中而不是进入 `args`,Client 生成的方法则接受最后一个可选的 `AbortSignal`。 +`@Remote({ mode: 'stream' })` 标记返回 `Iterable` 或 `AsyncIterable` 的方法:Gateway 校验每个产出的项,并经其多路复用的 `/api/remote.mux` WebSocket 或进程内载体投递,Client 生成的方法返回 `AsyncIterable`。`@Remote({ mode: 'duplex' })` 增加 Client 到 Host 的方向:Host 签名在可选的 `signal` 之前紧邻声明保留参数 `uplink: AsyncIterable`,Client 生成的方法在同一位置接受 `AsyncIterable`,Gateway 在 Host 方法读取之前用生成的 `In` codec 校验 Client 发送的每一项。与 `signal` 相同,`uplink` 记录在描述符中而不进入 `args`;帧、半关闭、取消与 inbox 上限的约定由 [Gateway README](../packages/api/gateway/README.zh.md) 拥有。 + Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接调用与作用域调用分别出现在 `ctx.remote.` 和 `agentCtx.remote.`。每个 namespace 都是注册为 `remote.` 的可追踪 Cordis 子服务;Client assembly 通过 `ctx.remote.$mount()` 挂载贡献,最后一个方法撤回后该 namespace 随即卸载。依赖声明归实际调用方所有:只有读取 `ctx.remote.` 或 `agentCtx.remote.` 的业务包才在自己的 `inject` 中同时声明 `remote` 与 `remote.`;只负责挂载 contribution 的 assembly,以及不调用该 namespace 的上层运行时,不代业务包声明 namespace 依赖。当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypertContextMap` 使用相同 wire identity 时,生成的作用域签名会省略该 identity 参数。`@RemoteScope` 只生成作用域调用接口。 ```ts ignore-check @@ -156,7 +158,7 @@ pnpm run build:lib ## 边界 -Remote 只处理有单个请求与单个结果的一元方法调用。会话事件流、分页、增量 reduce、projection 和实体子流需要独立的数据协议与注册模型;即使它们复用 Connection,也不应伪装成 Remote 方法或放入调用描述符。 +Remote 处理单请求单结果的一元方法调用、项沿 Host → Client 方向流动的流方法,以及 Client 的项经保留参数 `uplink` 到达正在运行的 Host 方法的双工方法。会话事件流、分页、增量 reduce、projection 和实体子流仍需要独立的数据协议与注册模型;即使它们复用 Connection,也不应伪装成 Remote 方法或放入调用描述符。 API 各层按 `remotes → gateway → connection → webserver` 组织。BFF 与 Typert RPC 层位于 `packages/api`;Connection 与 WebServer 位于 `packages/client/connection` 和 `packages/host/webserver`。需要流式或浏览器原生响应的功能注册精确的 Connection Fetch 路由,而不定义 Remote 方法。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 8e4853c16c..4f95ff65f0 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: 460d7a69837cdc43fe089b164b4d889b7e696650 -config-catalog.zh.md: 46b6a74511c89e806416770318a8244a94a45964 +config-catalog.md: a740e866ec458ab116cdf10cf5733497886a8ba8 +config-catalog.zh.md: d21f4d5701b12a39b5a620c337f5ab93b93bbb5a diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 460d7a6983..a740e866ec 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -194,10 +194,12 @@ Requires: `typert` export interface Config { /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 2000 */ readonly websocketHeartbeatIntervalMs?: number + /** Buffered uplink frame bytes one duplex logical stream may hold before it fails with `gateway/uplink-overflow`. @default 262144 */ + readonly duplexInboxBytes?: number } ``` -Source: [`packages/api/gateway/src/index.ts:119`](../packages/api/gateway/src/index.ts) +Source: [`packages/api/gateway/src/index.ts:128`](../packages/api/gateway/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 46b6a74511..d21f4d5701 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -196,10 +196,12 @@ Depends on: [`ToolPresentationMode`](subsystems/tools.zh.md) export interface Config { /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 2000 */ readonly websocketHeartbeatIntervalMs?: number + /** Buffered uplink frame bytes one duplex logical stream may hold before it fails with `gateway/uplink-overflow`. @default 262144 */ + readonly duplexInboxBytes?: number } ``` -来源:[`packages/api/gateway/src/index.ts:119`](../packages/api/gateway/src/index.ts) +来源:[`packages/api/gateway/src/index.ts:128`](../packages/api/gateway/src/index.ts) diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index fb6146eda0..9b313eb36f 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/typert.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/typert.md -typert.md: 954c3dfb7e66b100132c6d4604c28f06fba04916 -typert.zh.md: dd2becdc43871ddbecdfb45909e076435ab5a447 +typert.md: 6d398fbb29a679a3830c9913c93bb4c8d7513f7d +typert.zh.md: 738ac31d854264ebdd1994df8e162e469bd3884d diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index 954c3dfb7e..6d398fbb29 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -85,8 +85,8 @@ interface InvocationDescriptor { readonly method: string /** Service member invoked when the exported method name is an alias. */ readonly implementation?: string - /** Absent for unary calls; stream calls validate and deliver every yielded item. */ - readonly mode?: 'stream' + /** Absent for unary calls; stream and duplex calls validate and deliver every yielded item. */ + readonly mode?: 'stream' | 'duplex' /** Receiver selection mode. */ readonly invocation: | { readonly kind: 'direct' } @@ -105,6 +105,13 @@ interface InvocationDescriptor { } /** Ordered business parameters. */ readonly parameters: readonly InvocationParameterDescriptor[] + /** Duplex only: Client-to-Host item stream injected before `signal` instead of entering wire args. */ + readonly uplink?: { + /** Reserved Host method parameter preceding the optional `signal`. */ + readonly parameter: 'uplink' + /** Codec validating every uplink item before it reaches the Host method. */ + readonly codec: TypertCodec + } /** Transport cancellation injected after business parameters instead of entering wire args. */ readonly cancellation?: { /** Reserved final Host method parameter. */ @@ -151,6 +158,8 @@ interface InvokeRemoteRequest { readonly method: string /** Named wire values; fields must exactly match the descriptor. */ readonly args: Readonly> + /** Duplex uplink items decoded per item before reaching the method; absent means an immediately ended iterable. */ + readonly uplink?: AsyncIterable /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ readonly signal?: AbortSignal } @@ -172,10 +181,12 @@ type TypertGatewayErrorCode = | 'gateway/lookup-not-found' | 'gateway/lookup-unavailable' | 'gateway/method-unavailable' + | 'gateway/protocol' | 'gateway/provider-mismatch' | 'gateway/result-invalid' | 'gateway/service-unavailable' | 'gateway/signature-invalid' + | 'gateway/uplink-overflow' ``` ```ts type-equiv @@ -201,8 +212,8 @@ interface TypertGateway { */ invoke(request: InvokeRemoteRequest): Promise /** - * Open one live stream Remote method without assuming a physical carrier. - * @param request - decoded endpoint and named wire arguments. + * Open one live stream or duplex Remote method without assuming a physical carrier. + * @param request - decoded endpoint, named wire arguments, and the duplex uplink when the method reads one. * @returns a cancellation-aware iterable over the business results. */ stream(request: InvokeRemoteRequest): Promise> @@ -333,8 +344,8 @@ registerRemoteEvents( source: TypertRemoteEventSource, host: RemoteEventHostInfo async invoke(request: InvokeRemoteRequest): Promise /** - * Open one live stream Remote method without assuming a physical carrier. - * @param request - decoded endpoint and named wire arguments. + * Open one live stream or duplex Remote method without assuming a physical carrier. + * @param request - decoded endpoint, named wire arguments, and the duplex uplink when the method reads one. * @returns a cancellation-aware iterable over the business results. */ async stream(request: InvokeRemoteRequest): Promise> diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index dd2becdc43..738ac31d85 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -85,8 +85,8 @@ interface InvocationDescriptor { readonly method: string /** Service member invoked when the exported method name is an alias. */ readonly implementation?: string - /** Absent for unary calls; stream calls validate and deliver every yielded item. */ - readonly mode?: 'stream' + /** Absent for unary calls; stream and duplex calls validate and deliver every yielded item. */ + readonly mode?: 'stream' | 'duplex' /** Receiver selection mode. */ readonly invocation: | { readonly kind: 'direct' } @@ -105,6 +105,13 @@ interface InvocationDescriptor { } /** Ordered business parameters. */ readonly parameters: readonly InvocationParameterDescriptor[] + /** Duplex only: Client-to-Host item stream injected before `signal` instead of entering wire args. */ + readonly uplink?: { + /** Reserved Host method parameter preceding the optional `signal`. */ + readonly parameter: 'uplink' + /** Codec validating every uplink item before it reaches the Host method. */ + readonly codec: TypertCodec + } /** Transport cancellation injected after business parameters instead of entering wire args. */ readonly cancellation?: { /** Reserved final Host method parameter. */ @@ -151,6 +158,8 @@ interface InvokeRemoteRequest { readonly method: string /** Named wire values; fields must exactly match the descriptor. */ readonly args: Readonly> + /** Duplex uplink items decoded per item before reaching the method; absent means an immediately ended iterable. */ + readonly uplink?: AsyncIterable /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ readonly signal?: AbortSignal } @@ -172,10 +181,12 @@ type TypertGatewayErrorCode = | 'gateway/lookup-not-found' | 'gateway/lookup-unavailable' | 'gateway/method-unavailable' + | 'gateway/protocol' | 'gateway/provider-mismatch' | 'gateway/result-invalid' | 'gateway/service-unavailable' | 'gateway/signature-invalid' + | 'gateway/uplink-overflow' ``` ```ts type-equiv @@ -201,8 +212,8 @@ interface TypertGateway { */ invoke(request: InvokeRemoteRequest): Promise /** - * Open one live stream Remote method without assuming a physical carrier. - * @param request - decoded endpoint and named wire arguments. + * Open one live stream or duplex Remote method without assuming a physical carrier. + * @param request - decoded endpoint, named wire arguments, and the duplex uplink when the method reads one. * @returns a cancellation-aware iterable over the business results. */ stream(request: InvokeRemoteRequest): Promise> @@ -333,8 +344,8 @@ registerRemoteEvents( source: TypertRemoteEventSource, host: RemoteEventHostInfo async invoke(request: InvokeRemoteRequest): Promise /** - * Open one live stream Remote method without assuming a physical carrier. - * @param request - decoded endpoint and named wire arguments. + * Open one live stream or duplex Remote method without assuming a physical carrier. + * @param request - decoded endpoint, named wire arguments, and the duplex uplink when the method reads one. * @returns a cancellation-aware iterable over the business results. */ async stream(request: InvokeRemoteRequest): Promise> diff --git a/packages/api/README.i18n.yaml b/packages/api/README.i18n.yaml index 86d8d25086..7c218129c3 100644 --- a/packages/api/README.i18n.yaml +++ b/packages/api/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/api/README.md -README.md: f1aefa7d4564539f94202e869bacb85857f939d5 -README.zh.md: 870bbd32552166fdbb06221baaf54b9a130c6069 +README.md: 94e7ae897d137a36a365a9c45b6ef9fa6617a624 +README.zh.md: 4473ee9de0a8634a9dbc9b485a366457b0ca6b18 diff --git a/packages/api/README.md b/packages/api/README.md index f1aefa7d45..94e7ae897d 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The `api/` group provides the application's Remote layer: a Client environment can call the business capabilities running on the Host — manage goals, run commands, list the plugin inventory, discover file and session references — as typed method calls, and receive the results or forwarded Host events. `remotes` decides which capabilities are exposed and how each call reaches the right session's agent; `gateway` carries the calls and their results between Client and Host. The stack runs over the application's shared Connection; streaming session data is deliberately outside it. +The `api/` group provides the application's Remote layer: a Client environment can call the business capabilities running on the Host — manage goals, run commands, list the plugin inventory, discover file and session references — as typed method calls, receive results or streams, send duplex uplinks, and observe forwarded Host events. `remotes` decides which capabilities are exposed and how each call reaches the right session's agent; `gateway` carries the calls and their results between Client and Host. The stack runs over the application's shared Connection; streaming session data is deliberately outside it. ## Table of Contents @@ -27,14 +27,14 @@ The packages below provide the Remote layer; the package READMEs own the exhaust | Package | Role | ctx key | |---|---|---| | [`remotes/`](remotes/README.md) | Chooses which Host capabilities and events the Client can consume. | — | -| [`gateway/`](gateway/README.md) | Carries typed unary calls, multiplexed streams, and forwarded Host events. | `ctx.typertGateway` / `ctx.remote` | +| [`gateway/`](gateway/README.md) | Carries typed unary calls, multiplexed streams with optional duplex uplinks, and forwarded Host events. | `ctx.typertGateway` / `ctx.remote` | | [`session-controller/`](session-controller/README.md) | Owns Session commands, history streams, live control state, and Agent/Session identity policy. | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.md) | Owns the configuration-surface reads and writes over the settings-domain seams. | `ctx.settingsController`, `ctx.credentialsController` / `ctx.remote.settings`, `ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.md) | Owns Workspace mutations and the complete Client Workspace projection. | `ctx.workspaceController` / `ctx.remote.workspace` | | [`terminal-controller/`](terminal-controller/README.md) | Session-owned interactive shells, screen recovery and browser terminal control. | `ctx.terminalController` / `ctx.remote.terminal` | | [`workspace-files/`](workspace-files/README.md) | Owns bounded workspace file access — `stat`, paged `read`, `list`, and the instrumented-operation `changes` feed — and the Client `file` resource provider over it. | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` | -Remote calls run Client → Host over the application's shared Connection. API Gateway owns Remote transport, while the controller packages own Session, configuration-surface, and Workspace behavior. Feature packages register exact Connection Fetch routes for responses that do not fit Remote invocation, such as streamed downloads. +Remote calls run Client → Host over the application's shared Connection; stream items flow Host → Client on the Gateway mux, and a duplex method's uplink items flow Client → Host on the same logical stream. API Gateway owns Remote transport, while the controller packages own Session, configuration-surface, and Workspace behavior. Feature packages register exact Connection Fetch routes for responses that do not fit Remote invocation, such as streamed downloads. ----- diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md index 870bbd3255..4473ee9de0 100644 --- a/packages/api/README.zh.md +++ b/packages/api/README.zh.md @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -`api/` 组提供应用的 Remote 层:Client 环境可以调用运行在 Host 上的业务能力——管理目标、运行命令、查看插件清单、发现文件与会话引用——调用方式是类型化方法,并接收结果或转发的 Host 事件。`remotes` 决定暴露哪些能力、以及每次调用如何到达正确会话的 agent;`gateway` 在 Client 与 Host 之间承载调用及其结果。技术栈运行在应用共享的 Connection 之上;流式会话数据刻意不在其中。 +`api/` 组提供应用的 Remote 层:Client 环境可以调用运行在 Host 上的业务能力——管理目标、运行命令、查看插件清单、发现文件与会话引用——调用方式是类型化方法,可接收结果或流、发送双工上行,并观察转发的 Host 事件。`remotes` 决定暴露哪些能力、以及每次调用如何到达正确会话的 agent;`gateway` 在 Client 与 Host 之间承载调用及其结果。技术栈运行在应用共享的 Connection 之上;流式会话数据刻意不在其中。 ## 目录 @@ -27,14 +27,14 @@ kind: "package-group" | 包 | 职责 | ctx key | |---|---|---| | [`remotes/`](remotes/README.zh.md) | 决定 Client 可以消费哪些 Host 能力与事件。 | — | -| [`gateway/`](gateway/README.zh.md) | 承载类型化一元调用、多路复用流与转发的 Host 事件。 | `ctx.typertGateway` / `ctx.remote` | +| [`gateway/`](gateway/README.zh.md) | 承载类型化一元调用、可带双工上行的多路复用流,以及转发的 Host 事件。 | `ctx.typertGateway` / `ctx.remote` | | [`session-controller/`](session-controller/README.zh.md) | 拥有会话命令、历史记录流、实时控制状态与 Agent/Session 身份策略。 | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.zh.md) | 拥有 settings 域各 seam 之上的配置界面读写。 | `ctx.settingsController`、`ctx.credentialsController` / `ctx.remote.settings`、`ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.zh.md) | 拥有 Workspace 变更与完整 Client Workspace 投影。 | `ctx.workspaceController` / `ctx.remote.workspace` | | [`terminal-controller/`](terminal-controller/README.zh.md) | Session 拥有的交互式 shell、屏幕恢复和浏览器终端控制。 | `ctx.terminalController` / `ctx.remote.terminal` | | [`workspace-files/`](workspace-files/README.zh.md) | 拥有有界的工作区文件访问——`stat`、分页 `read`、`list` 与已埋点操作的 `changes` 流——以及其上的 Client `file` 资源提供方。 | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` | -Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上。API Gateway 拥有 Remote 传输,各控制器包分别拥有 Session、配置界面与 Workspace 行为。流式下载等不适合 Remote 调用的响应由功能包注册精确的 Connection Fetch 路由。 +Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上;流项沿 Host → Client 方向经 Gateway mux 传输,双工方法的上行项则沿 Client → Host 方向走同一条逻辑流。API Gateway 拥有 Remote 传输,各控制器包分别拥有 Session、配置界面与 Workspace 行为。流式下载等不适合 Remote 调用的响应由功能包注册精确的 Connection Fetch 路由。 ----- diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 366e9297fe..d1eaf4caeb 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/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/api/gateway/README.md -README.md: 1460f5a27062ffcac09c68358dc7b186241a1baa -README.zh.md: 79ed379faad8498cfdf249a0accb437f6d5f98e3 +README.md: 39c15e2a3e67dd09d201ae9b8d415b180015ba6f +README.zh.md: 65e1c88011824e6f2f9c7ff558a8238d850546cf diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 1460f5a270..39c15e2a3e 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.remote`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes. Connection carries unary request correlation, trust, and response envelopes, while Gateway owns multiplexed Remote streams. +Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.remote`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes. Connection carries unary request correlation, trust, and response envelopes, while Gateway owns multiplexed Remote streams, including the Client-to-Host uplink of duplex methods. ## Table of Contents @@ -28,12 +28,14 @@ Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context adapter. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. -The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and returns 404 for unclaimed requests unless an exact Fetch route owns them. Direct `invoke()` calls preserve business errors; `TypertGatewayError` is a `RemoteError` subclass whose `gateway/*` codes name the failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. A resolver that refuses on policy grounds — a cold-resume failure or an ownership fence — throws its own `RemoteError`, and the code it chose reaches the caller unchanged. +The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and returns 404 for unclaimed requests unless an exact Fetch route owns them. Direct `invoke()` calls preserve business errors; `TypertGatewayError` is a `RemoteError` subclass whose `gateway/*` codes name the failures owned by dispatch, binding, providers, lookup, Context, arguments, codecs, and the duplex uplink: `gateway/uplink-overflow` for an uplink whose buffered frames exceed `duplexInboxBytes`, `gateway/protocol` for an uplink item after `end` or on a stream method, and the codec code `gateway/input-invalid` for a rejected uplink item. A resolver that refuses on policy grounds — a cold-resume failure or an ownership fence — throws its own `RemoteError`, and the code it chose reaches the caller unchanged. A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type. A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then returns a cancellation-aware iterable over the business items. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates and keeps it connected while idle. Connection owns the retry schedule; before each retry it asks the mux to cancel any candidate or active socket and make exactly one fresh physical attempt. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (two seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. A socket that has not answered the previous Ping is terminated at the next interval. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. +A duplex Remote uses `@Remote({ mode: 'duplex' })`, declares the reserved `uplink: AsyncIterable` parameter immediately before the optional `signal`, and returns an `Iterable` or `AsyncIterable` like a stream. Like `signal`, `uplink` is descriptor metadata rather than a wire argument: the Client sends each uplink item as an `item` frame on the open logical stream and its end as an `end` frame, and the Gateway decodes every item with the descriptor's uplink codec before the method reads it. The Host method observes the Client's half-close as the end of the `uplink` iteration and cancellation through `signal`; a rejected uplink item fails the whole stream with `gateway/input-invalid`, and when the method finishes its downlink the Gateway returns the `uplink` iterator and drops unread items. Each logical stream buffers at most `duplexInboxBytes` (262144 by default) of uplink frame bytes; overflow fails the stream with `gateway/uplink-overflow`, and an item after `end` or on a stream method fails it with `gateway/protocol`, in both cases without closing the socket. SRC recognizes the reserved `uplink` name, while strict generation additionally requires the global `AsyncIterable` type; an in-process carrier hands the caller's iterable to the Gateway directly. + Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, per-Client queues, and the Host home sent in the opening `{ type: 'ready', clientId, host: { home } }` frame. Its source factory attaches incremental listeners synchronously, so the Client publishes the generation and starts baseline reads only after incremental delivery is ready. @@ -43,7 +45,7 @@ The browser carrier accepts the shell-owned stream origin described by [Connecti `ctx.remote.$mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Each namespace is a traced `remote.` child Service and unloads after its last method is withdrawn. Duplicate endpoints, namespace collisions, and Client-supplied fields without strict generated codecs fail before methods become callable. -Each unary call checks positional arity, constructs the descriptor's exact named `args`, and sends the typed values through `ctx.connection.rpc.call('/api', endpoint, ...)` without executing Client-side schemas; the Host validates the received wire fields before business invocation. A generated stream method returns an `AsyncIterable` and opens one logical stream through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before invoking the carrier. Successful unary results and stream items pass through without Client-side type parsing. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject. +Each unary call checks positional arity, constructs the descriptor's exact named `args`, and sends the typed values through `ctx.connection.rpc.call('/api', endpoint, ...)` without executing Client-side schemas; the Host validates the received wire fields before business invocation. A generated stream method returns an `AsyncIterable` and opens one logical stream through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. A generated duplex method additionally takes `uplink: AsyncIterable` after its business arguments: the Client sends each item the iterable yields once the stream is open and `end` when it finishes, calls the iterable's `return()` when the downlink ends first, and cancels the stream and fails the downlink iterator when the iterable throws. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before invoking the carrier. Successful unary results and stream items pass through without Client-side type parsing. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject. Every unary call resolves to `RemoteResult` — `{ ok: true, value }` or `{ ok: false, error }` — and never rejects for a carrier problem: this face folds an offline carrier into the error branch and answers `gateway/cancelled` when the caller's signal aborts, so no consumer wraps a call to recover one. Only an assembly fault still rejects: wrong arity, an unmounted method, a withdrawn contribution, a missing Context adapter. `error` is a live `RemoteError` instance, so `throw result.error` keeps throw semantics, and `isRemoteFailure(value)` is the one predicate a consumer needs — a caught value it accepts carries a Host code, and anything it rejects is a local fault the caller should let crash. `carrierFailure(endpoint, error)` and `cancelledFailure(endpoint, cause)` build those two folded results, so a test stand-in for this face folds identically. @@ -79,6 +81,7 @@ No direct effect; invoked business Services own any model-visible result. - Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key. - Forwarded events reach `$on` without business-payload projection or redaction. Ordinary notifications are not replayed after reconnect; Agent-scoped waterfalls project only the top-level Agent identity needed to select the Client Context and carry their own pending lifetime. - `websocketHeartbeatIntervalMs` is both the Ping cadence and the Pong deadline. The Host terminates a peer that does not answer before the next interval, so a deployment whose event loop or network can stall longer than this interval must raise it. +- Duplex uplinks have no flow control beyond the bounded Host inbox: a Client that sends faster than the method reads fails its stream with `gateway/uplink-overflow`, and no uplink item is replayed across carrier generations; a domain that must resume an uplink carries its own acknowledgement cursor in the reopened request. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 79ed379faa..65e1c88011 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -为 Host 与 Client 两侧的 Cordis 环境提供 Typert RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 约定,并将业务选择交给 API Remotes。Connection 承载一元调用的请求关联、信任和响应 envelope,Gateway 则拥有多路复用的 Remote 流。 +为 Host 与 Client 两侧的 Cordis 环境提供 Typert RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 约定,并将业务选择交给 API Remotes。Connection 承载一元调用的请求关联、信任和响应 envelope,Gateway 则拥有多路复用的 Remote 流,包括双工方法从 Client 到 Host 的上行(uplink)。 ## 目录 @@ -28,12 +28,14 @@ kind: "package-reference" 严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的解析器:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context 适配器解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 -Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领且没有精确 Fetch 路由负责的请求返回 404。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 是 `RemoteError` 的子类,其 `gateway/*` 码命名了分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。因策略而拒绝的解析器——冷恢复失败或 ownership fence——抛出自己的 `RemoteError`,它选定的码原样到达调用方。 +Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领且没有精确 Fetch 路由负责的请求返回 404。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 是 `RemoteError` 的子类,其 `gateway/*` 码命名了分发、绑定、提供方、查找、Context、参数、编解码器以及双工上行各自负责的故障:缓冲帧超过 `duplexInboxBytes` 的上行报 `gateway/uplink-overflow`,`end` 之后或发给流方法的上行项报 `gateway/protocol`,被 codec 拒绝的上行项报编解码码 `gateway/input-invalid`。因策略而拒绝的解析器——冷恢复失败或 ownership fence——抛出自己的 `RemoteError`,它选定的码原样到达调用方。 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再返回可取消的业务项 iterable。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,并让它在空闲时保持连接。Connection 拥有重试调度;每次 retry 前,它要求 mux 取消候选或活动 socket,并且只做一次全新的物理连接尝试。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 2 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote 流帧。若 socket 尚未回复上一次 Ping,Host 会在下一间隔终止它。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 +双工 Remote 使用 `@Remote({ mode: 'duplex' })`,在可选的 `signal` 之前紧邻声明保留参数 `uplink: AsyncIterable`,并像流方法一样返回 `Iterable` 或 `AsyncIterable`。与 `signal` 相同,`uplink` 是 descriptor 元数据而不是 wire 参数:Client 在已打开的逻辑流上把每个上行项作为 `item` 帧发送,并以 `end` 帧表示上行结束;Gateway 在方法读取每一项之前用描述符的 uplink codec 解码它。Host 方法通过 `uplink` 迭代结束感知 Client 的半关闭,通过 `signal` 感知取消;被拒绝的上行项使整条流以 `gateway/input-invalid` 失败,方法结束下行时 Gateway 会调用 `uplink` 迭代器的 return 并丢弃未读的项。每条逻辑流最多缓冲 `duplexInboxBytes`(默认 262144)字节的上行帧;超限使流以 `gateway/uplink-overflow` 失败,`end` 之后的项或发给流方法的项使流以 `gateway/protocol` 失败,两种情况都不关闭 socket。SRC 识别保留名 `uplink`,严格生成还要求它具有全局 `AsyncIterable` 类型;进程内载体把调用方的 iterable 直接交给 Gateway。 + Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的流。事件名单、参数校验、每个 Client 的队列及 opening `{ type: 'ready', clientId, host: { home } }` frame 中的 Host home 由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener,因此 Client 只在增量投递就绪后发布 generation 并开始 baseline 读取。 @@ -43,7 +45,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source `ctx.remote.$mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。每个 namespace 都是可追踪的 `remote.` 子 Service,并在最后一个方法撤回后卸载。重复端点、命名空间冲突,以及缺少生成的严格 codec 的 Client 供值字段,都会在方法可调用前报错。 -每次一元调用都会检查位置参数数量,构造与描述符完全匹配的具名 `args`,再把带类型的值原样交给 `ctx.connection.rpc.call('/api', endpoint, ...)`,而不执行 Client 侧 schema;Host 会在业务调用前校验收到的 wire 字段。生成的流方法返回 `AsyncIterable`,并在进程内 Connection 载体可用时通过它打开逻辑流,否则通过共享的 Gateway WebSocket 打开。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。成功的一元结果与流项不经 Client 侧类型解析直接传递。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。 +每次一元调用都会检查位置参数数量,构造与描述符完全匹配的具名 `args`,再把带类型的值原样交给 `ctx.connection.rpc.call('/api', endpoint, ...)`,而不执行 Client 侧 schema;Host 会在业务调用前校验收到的 wire 字段。生成的流方法返回 `AsyncIterable`,并在进程内 Connection 载体可用时通过它打开逻辑流,否则通过共享的 Gateway WebSocket 打开。生成的双工方法还在业务参数之后接受 `uplink: AsyncIterable`:流打开后 Client 逐项发送该 iterable 产出的项,迭代结束时发送 `end`;下行先结束时调用该 iterable 的 `return()`;iterable 抛错时取消整条流,并让下行迭代器以该错误失败。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。成功的一元结果与流项不经 Client 侧类型解析直接传递。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。 每次一元调用都解析为 `RemoteResult`——`{ ok: true, value }` 或 `{ ok: false, error }`——且绝不因载体问题 reject:本面把断线载体折入错误分支,调用方 signal 中止时答以 `gateway/cancelled`,因此没有消费方需要包一层来兜载体失败。只有装配故障仍会 reject:参数个数不符、方法未挂载、贡献已撤下、缺少 Context 适配器。`error` 是活的 `RemoteError` 实例,所以 `throw result.error` 保持 throw 语义;而 `isRemoteFailure(value)` 是消费方唯一需要的谓词——它认下的捕获值带着 Host 码,它拒绝的一律是本地故障,调用方应当让其崩掉。`carrierFailure(endpoint, error)` 与 `cancelledFailure(endpoint, cause)` 构造这两种折叠结果,测试里的替代实现据此采用相同的折叠方式。 @@ -79,6 +81,7 @@ Client waterfall 的 Context 解析保持同步。解析器可以返回借用的 - lookup 解析器按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。 - 被转发的事件到达 `$on` 时不做业务载荷投影或脱敏。普通通知在重连后不重放;Agent-scoped waterfall 只投影选择 Client Context 所需的顶层 Agent 身份,并自行携带 pending 生命周期。 - `websocketHeartbeatIntervalMs` 同时是 Ping 周期和 Pong 截止时间。对端未在下一周期前回复时,Host 会终止连接;如果部署的事件循环或网络可能停顿超过该间隔,必须调大此配置。 +- 双工上行除了有界的 Host inbox 之外没有流控:Client 发送快于方法读取时,其流以 `gateway/uplink-overflow` 失败;上行项不会跨载体代际重放,需要恢复上行的领域在重开的请求里自带确认游标。 diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index 01784c9d68..b2a87ce222 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -5,7 +5,9 @@ */ import { Service } from '@deepseek-ai/cordis' +import { Deque } from '@deepseek-ai/dsh-deque' import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol/client' export type { TypertGatewayFaultDetails } from '../remote-error-codes.ts' import type { Context } from '@deepseek-ai/cordis' import type { @@ -80,6 +82,8 @@ interface PreparedClientInvocation { readonly signal: AbortSignal } +const UPLINK_DONE: IteratorReturnResult = { value: undefined, done: true } + interface RemoteNamespaceHandle { readonly service: RemoteNamespaceService readonly dispose: TypertDisposer @@ -219,13 +223,14 @@ class ClientRemoteService extends Service implements ClientRemote { endpoint: string, payload: unknown, signal: AbortSignal, + uplink?: AsyncIterable, noConnection = `client api: ${endpoint} has no active Connection`, ): AsyncIterable { const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined if (connection === undefined) throw new Error(noConnection) - const local = connection.rpc.open?.('/api', endpoint, payload, signal) + const local = connection.rpc.open?.('/api', endpoint, payload, signal, uplink) return local === undefined - ? this.streams.open(endpoint, payload, signal) + ? this.streams.open(endpoint, payload, signal, uplink) : normalizeConnectionStream(local) } @@ -387,7 +392,7 @@ class ClientRemoteService extends Service implements ClientRemote { scoped: ScopedMethod | undefined, callerCtx: Context, values: readonly unknown[], - ): Promise> | AsyncIterable { + ): Promise> | RemoteStreamHandle { if (scoped !== undefined) { const adapter = this.ownerCtx.typert.contexts.getClient(scoped.projection.context) const identity = adapter?.identity(callerCtx) @@ -418,8 +423,8 @@ class ClientRemoteService extends Service implements ClientRemote { callerCtx: Context, values: readonly unknown[], boundIdentity?: BoundContextIdentity, - ): Promise> | AsyncIterable { - if (descriptor.mode === 'stream') { + ): Promise> | RemoteStreamHandle { + if (descriptor.mode !== undefined) { return this.invokeStream(descriptor, projection, token, callerCtx, values, boundIdentity) } return this.invoke(descriptor, projection, token, callerCtx, values, boundIdentity) @@ -453,22 +458,27 @@ class ClientRemoteService extends Service implements ClientRemote { } } - private async *invokeStream( + /** Open the logical stream now and hand back its handle; `send()` before the first read queues behind the `open` frame. */ + private invokeStream( descriptor: InvocationDescriptor, projection: ScopedProjection | undefined, token: MountToken, callerCtx: Context, values: readonly unknown[], boundIdentity?: BoundContextIdentity, - ): AsyncGenerator { + ): RemoteStreamHandle { const endpoint = endpointOf(descriptor) if (!token.active) throw new Error(withdrawn(endpoint).error.message) const prepared = this.prepareInvocation(descriptor, projection, token, callerCtx, values, boundIdentity) - const stream = this.openRemoteStream(endpoint, { args: prepared.args }, prepared.signal) - for await (const value of stream) { - if (!mountActive(token)) throw new Error(withdrawn(endpoint).error.message) - yield value - } + const generation = new AbortController() + const uplink = new ClientUplinkQueue(endpoint) + const downlink = this.openRemoteStream( + endpoint, + { args: prepared.args }, + AbortSignal.any([prepared.signal, generation.signal]), + uplink, + ) + return new ClientStreamHandle(endpoint, downlink, uplink, generation, token) } private prepareInvocation( @@ -521,12 +531,140 @@ class ClientRemoteService extends Service implements ClientRemote { } } +/** + * The handle a generated stream method returns: one generation of one logical + * stream. The downlink is iterated once; `send`/`end` feed the uplink queue the + * carrier pump drains; `dispose` aborts the generation, which sends `cancel` + * unless a terminal frame arrived, and the iterator then ends quietly. + */ +class ClientStreamHandle implements RemoteStreamHandle { + private readonly downlink: AsyncIterator + private primed: Promise> | undefined + private consumed = false + private disposed = false + + constructor( + private readonly endpoint: string, + downlink: AsyncIterable, + private readonly uplink: ClientUplinkQueue, + private readonly generation: AbortController, + private readonly token: MountToken, + ) { + this.downlink = downlink[Symbol.asyncIterator]() + // The carrier opens the logical stream on the first pull; pulling now puts + // the `open` frame on the wire before any `send()`. The first read is kept + // for the consumer, and a failure waits for it instead of surfacing here. + this.primed = this.downlink.next() + void this.primed.catch(() => undefined) + } + + send(item: unknown): void { + this.uplink.push(item) + } + + end(): void { + this.uplink.end() + } + + dispose(): void { + if (this.disposed) return + this.disposed = true + this.uplink.close() + this.generation.abort(new Error(`client api: ${this.endpoint} stream disposed`)) + } + + [Symbol.asyncIterator](): AsyncGenerator { + if (this.consumed) throw new Error(`client api: ${this.endpoint} stream has one consumer`) + this.consumed = true + return this.iterate() + } + + private async *iterate(): AsyncGenerator { + try { + while (true) { + const next = await (this.primed ?? this.downlink.next()) + this.primed = undefined + if (next.done === true) return + if (!mountActive(this.token)) throw new Error(withdrawn(this.endpoint).error.message) + yield next.value + } + } catch (error) { + if (this.disposed) return + throw error + } finally { + // Ending early is a dispose; a terminated stream accepts no more uplink. + this.dispose() + await this.downlink.return?.() + } + } +} + +/** + * Uplink items a stream handle queues for its carrier: the mux pump or the + * in-process Host decoder iterates it as the stream's uplink. `end()` is the + * Client half-close; `close()` marks the stream terminated, after which + * `push()` throws. + */ +class ClientUplinkQueue implements AsyncIterable, AsyncIterator { + private readonly items = new Deque() + private ended = false + private closed = false + private wake: (() => void) | undefined + + constructor(private readonly endpoint: string) {} + + push(item: unknown): void { + if (this.closed) throw new Error(`client api: ${this.endpoint} stream has terminated`) + if (this.ended) throw new Error(`client api: ${this.endpoint} uplink was ended`) + this.items.pushBack(item) + this.signal() + } + + end(): void { + if (this.ended || this.closed) return + this.ended = true + this.signal() + } + + /** The carrier stopped reading: the logical stream terminated or was disposed. Idempotent. */ + close(): void { + if (this.closed) return + this.closed = true + this.items.clear() + this.signal() + } + + [Symbol.asyncIterator](): AsyncIterator { + return this + } + + async next(): Promise> { + while (true) { + if (this.closed) return UPLINK_DONE + if (this.items.size > 0) return { value: this.items.popFront(), done: false } + if (this.ended) return UPLINK_DONE + await new Promise((resolve) => { this.wake = resolve }) + } + } + + return(): Promise> { + this.close() + return Promise.resolve(UPLINK_DONE) + } + + private signal(): void { + const wake = this.wake + this.wake = undefined + wake?.() + } +} + type InvokeRemote = ( direct: DirectMethod | undefined, scoped: ScopedMethod | undefined, callerCtx: Context, args: readonly unknown[], -) => Promise> | AsyncIterable +) => Promise> | RemoteStreamHandle class RemoteNamespaceService extends Service { private readonly methods = new Map() @@ -698,6 +836,7 @@ function requireStrictInputs(descriptor: InvocationDescriptor): void { for (const parameter of descriptor.parameters) { requireStrictCodec(parameter.codec, endpoint, parameter.wire) } + if (descriptor.uplink !== undefined) requireStrictCodec(descriptor.uplink.codec, endpoint, 'uplink') if (descriptor.invocation.kind === 'context') { requireStrictCodec(descriptor.invocation.codec, endpoint, descriptor.invocation.wire) } diff --git a/packages/api/gateway/src/client/stream-client.ts b/packages/api/gateway/src/client/stream-client.ts index 13e107ade8..b67918995e 100644 --- a/packages/api/gateway/src/client/stream-client.ts +++ b/packages/api/gateway/src/client/stream-client.ts @@ -28,6 +28,13 @@ interface SocketWaiter { reject(error: unknown): void } +interface UplinkPump { + /** Settles once the pump has stopped sending. */ + readonly done: Promise + /** Interrupt the pump, including a read blocked on the caller's iterator. */ + stop(): void +} + /** * Keep one physical WebSocket and share it among independently cancellable * Remote streams. A carrier that supplies an in-process stream opener never @@ -77,12 +84,15 @@ export class RemoteStreamMuxClient { * @param endpoint - Typert Remote stream endpoint. * @param payload - endpoint request encoded on the wire. * @param signal - cancellation for this logical stream. + * @param uplink - the Client's items: each is sent as an `item` frame, its end as `end`; its `return()` + * runs when the stream finishes, and its failure cancels the stream and fails the downlink. * @returns Host items until completion, cancellation, or failure. */ async *open( endpoint: string, payload: unknown, signal: AbortSignal, + uplink?: AsyncIterable, ): AsyncGenerator { signal.throwIfAborted() const streamId = randomUUID() @@ -90,6 +100,7 @@ export class RemoteStreamMuxClient { let carrier: WebSocket | undefined let opened = false let terminal = false + let pump: UplinkPump | undefined const abort = (): void => { inbox.fail(signal.reason) } signal.addEventListener('abort', abort, { once: true }) try { @@ -99,6 +110,7 @@ export class RemoteStreamMuxClient { this.streams.set(streamId, inbox) this.send(socket, { type: 'open', streamId, endpoint, payload }) opened = true + if (uplink !== undefined) pump = this.pumpUplink(socket, streamId, uplink, signal, inbox) while (true) { const frame = await inbox.next() signal.throwIfAborted() @@ -115,9 +127,55 @@ export class RemoteStreamMuxClient { } finally { signal.removeEventListener('abort', abort) this.streams.delete(streamId) + pump?.stop() if (opened && !terminal && carrier?.readyState === WebSocket.OPEN) { this.send(carrier, { type: 'cancel', streamId }) } + // The old generation's pump has stopped before a supervisor reopens the next one. + if (pump !== undefined) await pump.done + } + } + + /** + * Send the caller's uplink items on this generation's socket. `stop()` + * interrupts a pump blocked on `uplink.next()`; the uplink iterator's + * `return()` is invoked without being awaited because a generator blocked in + * `next()` only completes it once it yields. + */ + private pumpUplink( + socket: WebSocket, + streamId: string, + uplink: AsyncIterable, + signal: AbortSignal, + inbox: StreamInbox, + ): UplinkPump { + const stopped = Promise.withResolvers>() + const interruption: IteratorReturnResult = { value: undefined, done: true } + const uplinkIterator = uplink[Symbol.asyncIterator]() + const done = (async (): Promise => { + let exhausted = false + try { + while (true) { + const next = await Promise.race([uplinkIterator.next(), stopped.promise]) + if (next === interruption || signal.aborted || this.socket !== socket) return + if (next.done === true) { + exhausted = true + break + } + this.send(socket, { type: 'item', streamId, value: next.value }) + } + this.send(socket, { type: 'end', streamId }) + } catch (error) { + inbox.fail(error) + } finally { + if (!exhausted) { + void Promise.resolve().then(() => uplinkIterator.return?.()).catch(() => undefined) + } + } + })() + return { + done, + stop: () => { stopped.resolve(interruption) }, } } diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 1fe2438876..3add5df299 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -19,6 +19,9 @@ import { remoteMethods, type InvocationDescriptor, type InvocationParameterDescriptor, + type PeerId, + type PeerScope, + type RemoteInvocation, type TypertCodec, type TypertGatewayBinding, } from '@deepseek-ai/dsh-typert-protocol' @@ -84,9 +87,22 @@ interface ResolvedBinding { interface PreparedInvocation { readonly endpoint: string readonly descriptor: InvocationDescriptor + /** Service view bound to a Context carrying `invocation`, so the method reads it as `this.ctx.invocation`. */ readonly receiver: object readonly args: readonly unknown[] readonly method: (...args: never[]) => unknown + readonly invocation: GatewayInvocation +} + +/** Carrier inputs `GatewayInvocation.uplink()` decodes on first use. */ +interface UplinkSource { + /** Carrier items; an immediately ended iterable when the carrier has none. */ + readonly source: AsyncIterable + /** Descriptor codec, or the JSON-safety codec when the descriptor declares no uplink. */ + readonly codec: TypertCodec + readonly endpoint: string + /** Fail the logical stream with a Remote failure as the reason. */ + readonly abort: (reason: unknown) => void } interface RegisteredRemoteEventSource { @@ -114,15 +130,24 @@ type ConnectionRpcResult = Awaited> type ConnectionRpcError = Extract['error'] const NEVER_ABORTED_SIGNAL = new AbortController().signal const DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS = 2_000 +const DEFAULT_STREAM_INBOX_BYTES = 262_144 +const EMPTY_ASYNC_ITERABLE: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ next: () => Promise.resolve({ value: undefined, done: true }) }), +} +const UPLINK_DONE: IteratorReturnResult = { value: undefined, done: true } +const SRC_JSON_CODEC: TypertCodec = { mode: 'src-json' } /** Gateway transport configuration. */ export interface Config { /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 2000 */ readonly websocketHeartbeatIntervalMs?: number + /** Buffered uplink frame bytes one logical stream may hold before it fails with `gateway/uplink-overflow`. @default 262144 */ + readonly streamInboxBytes?: number } interface ResolvedConfig extends Config { readonly websocketHeartbeatIntervalMs: number + readonly streamInboxBytes: number } /** @@ -171,15 +196,18 @@ export class TypertGatewayService extends Service implements TypertGateway { static Config: z = z.object({ websocketHeartbeatIntervalMs: z.number().step(1).min(1).max(MAX_TIMER_DELAY_MS) .default(DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS), + streamInboxBytes: z.number().step(1).min(1).default(DEFAULT_STREAM_INBOX_BYTES), }) /** Carrier adapter shared by the WebSocket mux and local Host transports. */ readonly wireStream: TypertGatewayWireStream = { - open: (endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal), + open: (endpoint, payload, uplink, peer, signal) => + this.openWireStream(endpoint, payload, uplink, peer, signal, new AbortController()), failure: error => rpcError(error), } private srcClaims: ReadonlySet | undefined + private inProcessOperator: PeerScope | undefined private remoteEvents: RegisteredRemoteEventSource | undefined private readonly remoteEventClients = new Map() private readonly pendingRemoteEvents = new Map() @@ -195,29 +223,35 @@ export class TypertGatewayService extends Service implements TypertGateway { ctx.on('internal/service', () => { this.srcClaims = undefined }) + // Outside a Remote call `ctx.invocation` reads as undefined instead of the + // reflect service's "cannot get property" error; a call-derived Context + // shadows this accessor with its own property. + ctx.accessor('invocation', { get: () => undefined }) ctx.inject(['connection'], (connectionCtx) => { connectionCtx.connection.rpc.intercept( '/api', endpoint => this.claimsEndpoint(endpoint), - (endpoint, payload, signal) => this.dispatchRpc(endpoint, payload, signal), + (endpoint, payload, signal, peer) => this.dispatchRpc(endpoint, payload, signal, peer), ) }) ctx.inject(['connection', 'webServer'], (webCtx) => { const mux = new RemoteStreamMuxServer( - (endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal), + (endpoint, payload, uplink, peer, control) => + this.openWireStream(endpoint, payload, uplink, peer, control.signal, control), this.wireStream.failure, resolved.websocketHeartbeatIntervalMs, + resolved.streamInboxBytes, ) webCtx.effect(() => { const route: WebUpgradeRoute = { path: REMOTE_STREAM_MUX_PATH, handler: (req, socket, head) => { - const rejection = webCtx.connection.requestRejection(req) - if (rejection !== undefined) { - rejectRemoteStreamUpgrade(socket, rejection) + const admission = webCtx.connection.admit(req) + if ('rejection' in admission) { + rejectRemoteStreamUpgrade(socket, admission.rejection) return } - mux.handleUpgrade(req, socket, head) + mux.handleUpgrade(req, socket, head, admission.peer) }, } const unregister = webCtx.webServer.registerUpgrade(route) @@ -296,8 +330,8 @@ export class TypertGatewayService extends Service implements TypertGateway { * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ async invoke(request: InvokeRemoteRequest): Promise { - const prepared = await this.prepareInvocation(request) - if (prepared.descriptor.mode === 'stream') { + const prepared = await this.prepareInvocation(request, new AbortController()) + if (prepared.descriptor.mode !== undefined) { throw new TypertGatewayError( 'gateway/signature-invalid', prepared.endpoint, @@ -310,17 +344,28 @@ export class TypertGatewayService extends Service implements TypertGateway { } catch (error) { if (request.signal?.aborted === true) throw remoteCancelled(prepared.endpoint, error) throw error + } finally { + // A unary call's uplink is readable only while the method runs. + await prepared.invocation.close() } } /** * Open one live stream Remote method without assuming a physical carrier. - * @param request - decoded endpoint and named wire arguments. + * @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one. * @returns a cancellation-aware iterable over the business results. */ async stream(request: InvokeRemoteRequest): Promise> { - const prepared = await this.prepareInvocation(request) - if (prepared.descriptor.mode !== 'stream') { + return this.openStream(request, new AbortController()) + } + + /** + * `control` belongs to the logical stream: a rejected uplink item aborts it + * with the Remote failure as the reason so the carrier delivers that failure. + */ + private async openStream(request: InvokeRemoteRequest, control: AbortController): Promise> { + const prepared = await this.prepareInvocation(request, control) + if (prepared.descriptor.mode === undefined) { throw new TypertGatewayError( 'gateway/signature-invalid', prepared.endpoint, @@ -331,10 +376,12 @@ export class TypertGatewayService extends Service implements TypertGateway { try { source = Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown } catch (error) { - if (request.signal?.aborted === true) throw remoteCancelled(prepared.endpoint, error) + await prepared.invocation.close() + if (prepared.invocation.signal.aborted) throw remoteCancelled(prepared.endpoint, error) throw error } if (!isIterable(source)) { + await prepared.invocation.close() throw new TypertGatewayError( 'gateway/result-invalid', prepared.endpoint, @@ -342,17 +389,14 @@ export class TypertGatewayService extends Service implements TypertGateway { { field: 'result' }, ) } - return cancellableStream( - source, - prepared.endpoint, - request.signal ?? NEVER_ABORTED_SIGNAL, - ) + return cancellableStream(source, prepared.endpoint, prepared.invocation) } private async dispatchRpc( endpoint: string, payload: unknown, signal: AbortSignal, + peer: PeerScope, ): Promise { if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) { try { @@ -367,18 +411,38 @@ export class TypertGatewayService extends Service implements TypertGateway { return rpcFailure(error) } } - return this.invokeRpc(endpoint, payload, signal) + return this.invokeRpc(endpoint, payload, signal, peer) } private async openWireStream( endpoint: string, payload: unknown, + uplink: AsyncIterable, + peer: PeerScope | undefined, signal: AbortSignal, + control: AbortController, ): Promise> { if (endpoint === REMOTE_EVENT_STREAM_ENDPOINT) { return this.openRemoteEvents(payload, signal) } - return this.stream(remoteRequest(endpoint, payload, signal)) + return this.openStream({ ...remoteRequest(endpoint, payload, signal, peer), uplink }, control) + } + + /** + * The Peer an in-process carrier speaks for when it names none: the + * operator's Peer when Connection is mounted, otherwise a Gateway-local + * stand-in that the Gateway treats the same way. + * @returns the operator Peer. + */ + private operatorPeer(): PeerScope { + const connection = this.ctx.get('connection') + if (connection !== undefined) return connection.peers.operator + this.inProcessOperator ??= { + id: 'in-process-operator' as PeerId, + ctx: this.ctx, + dispose: () => Promise.resolve(), + } + return this.inProcessOperator } private async *openRemoteEvents( @@ -582,9 +646,14 @@ export class TypertGatewayService extends Service implements TypertGateway { for (const client of [...this.remoteEventClients.values()]) client.queue.end() } - private async invokeRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise { + private async invokeRpc( + endpoint: string, + payload: unknown, + signal: AbortSignal, + peer: PeerScope, + ): Promise { try { - const value = await this.invoke(remoteRequest(endpoint, payload, signal)) + const value = await this.invoke(remoteRequest(endpoint, payload, signal, peer)) // A void or explicitly absent business result carries no `value` field; // JSON has no `undefined`, and the envelope's optional slot is the one // representation of absence that both args and results already use. @@ -594,7 +663,11 @@ export class TypertGatewayService extends Service implements TypertGateway { } } - private async prepareInvocation(request: InvokeRemoteRequest): Promise { + /** `control` fails the logical stream when an uplink item is rejected; unary calls hand over an inert one. */ + private async prepareInvocation( + request: InvokeRemoteRequest, + control: AbortController, + ): Promise { const endpoint = endpointOf(request.namespace, request.method) const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint) assertExactArguments(request.args, descriptor, endpoint) @@ -610,9 +683,27 @@ export class TypertGatewayService extends Service implements TypertGateway { validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint) const args = await Promise.all(descriptor.parameters.map(parameter => this.resolveParameter(parameter, request.args, endpoint))) - if (descriptor.cancellation !== undefined) args.push(request.signal ?? NEVER_ABORTED_SIGNAL) + const signal = methodSignal(request, control) + const invocation = new GatewayInvocation( + { namespace: request.namespace, method: request.method, args: request.args }, + descriptor.service, + request.peer ?? this.operatorPeer(), + signal, + { + source: request.uplink ?? EMPTY_ASYNC_ITERABLE, + codec: descriptor.uplink?.codec ?? SRC_JSON_CODEC, + endpoint, + abort: (reason) => { control.abort(reason) }, + }, + ) + if (descriptor.cancellation !== undefined) args.push(signal) + // The method runs on a Service view bound to a Context carrying this call: + // Cordis rebinds `this.ctx` to the accessing Context, so `this.ctx.invocation` + // is this call and nothing travels through the parameter list. The view + // resolves the Service the plain read above already found. + const callReceiver = receiverContext.extend({ invocation }).get(descriptor.service) as object const implementation = descriptor.implementation ?? descriptor.method - const method: unknown = Reflect.get(receiver, implementation) + const method: unknown = Reflect.get(callReceiver, implementation) if (typeof method !== 'function') { throw new TypertGatewayError( 'gateway/method-unavailable', @@ -620,7 +711,14 @@ export class TypertGatewayService extends Service implements TypertGateway { `active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`, ) } - return { endpoint, descriptor, receiver, args, method: method as (...args: never[]) => unknown } + return { + endpoint, + descriptor, + receiver: callReceiver, + args, + method: method as (...args: never[]) => unknown, + invocation, + } } private resolveDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor { @@ -933,7 +1031,12 @@ function parseRemoteEventResultPayload(payload: unknown): ReturnType | AsyncIterable { @@ -959,8 +1075,9 @@ function isIterable(value: unknown): value is Iterable | AsyncIterable< async function *cancellableStream( source: Iterable | AsyncIterable, endpoint: string, - signal: AbortSignal, + invocation: GatewayInvocation, ): AsyncGenerator { + const { signal } = invocation const asyncFactory: unknown = Reflect.get(source, Symbol.asyncIterator) const syncFactory: unknown = Reflect.get(source, Symbol.iterator) const iterator = typeof asyncFactory === 'function' @@ -969,11 +1086,11 @@ async function *cancellableStream( let rejectAbort: ((error: unknown) => void) | undefined const aborted = new Promise((_resolve, reject) => { rejectAbort = reject }) const onAbort = (): void => { - rejectAbort?.(remoteCancelled(endpoint, signal.reason)) + rejectAbort?.(streamAbortFailure(endpoint, signal.reason)) } signal.addEventListener('abort', onAbort, { once: true }) try { - if (signal.aborted) throw remoteCancelled(endpoint, signal.reason) + if (signal.aborted) throw streamAbortFailure(endpoint, signal.reason) while (true) { const next = await Promise.race([Promise.resolve(iterator.next()), aborted]) if (next.done === true) return @@ -982,14 +1099,148 @@ async function *cancellableStream( } finally { signal.removeEventListener('abort', onAbort) await iterator.return?.() + await invocation.close() } } +/** + * The failure a stream surfaces for its abort: a Remote failure used as the + * reason is the Gateway or carrier failing the stream itself (a rejected, + * overflowing, or misplaced uplink item); any other abort is a cancellation. + */ +function streamAbortFailure(endpoint: string, reason: unknown): unknown { + return remoteErrorOf(reason) === undefined ? remoteCancelled(endpoint, reason) : reason +} + /** Carrier-signal cancellation as the shared failure vocabulary expresses it. */ function remoteCancelled(endpoint: string, cause: unknown): RemoteError<'gateway/cancelled'> { return new RemoteError('gateway/cancelled', `Remote invocation "${endpoint}" was aborted`, {}, { cause }) } +/** + * The iterable `invocation.uplink()` returns. Each uplink item passes the + * descriptor codec, or the JSON-safety check when the descriptor declares no + * uplink, before delivery; a rejected item fails the whole logical stream. + * Iteration ends when the Client half-closes or the downlink finishes, and + * fails when the stream is cancelled, so a method blocked on the uplink always + * wakes. + */ +class UplinkDecoder implements AsyncIterable, AsyncIterator { + private readonly source: AsyncIterator + private readonly interrupted = Promise.withResolvers>() + private readonly onAbort = (): void => { + this.interrupted.reject(streamAbortFailure(this.endpoint, this.signal.reason)) + } + private closed = false + + constructor( + uplink: AsyncIterable, + private readonly codec: TypertCodec, + private readonly endpoint: string, + private readonly signal: AbortSignal, + private readonly abort: (reason: unknown) => void, + ) { + this.source = uplink[Symbol.asyncIterator]() + // The rejection settles the race inside next(); an abort with no read in + // flight must not surface as an unhandled rejection. + void this.interrupted.promise.catch(() => undefined) + signal.addEventListener('abort', this.onAbort, { once: true }) + } + + [Symbol.asyncIterator](): AsyncIterator { + return this + } + + async next(): Promise> { + if (this.closed) return UPLINK_DONE + if (this.signal.aborted) throw streamAbortFailure(this.endpoint, this.signal.reason) + const next = await Promise.race([this.source.next(), this.interrupted.promise]) + if (next.done === true) { + this.finish() + return UPLINK_DONE + } + let value: unknown + try { + value = decode(this.codec, next.value, this.endpoint, 'uplink') + } catch (failure) { + // The codec failure (`gateway/input-invalid`, field `uplink`) fails the whole logical stream. + this.abort(failure) + throw failure + } + return { value, done: false } + } + + /** Downlink finished or the method stopped reading: unread uplink items are dropped. */ + return(): Promise> { + if (!this.closed) { + this.finish() + // The carrier owns the source iterator; a generator blocked in next() + // completes this return once it yields, so it is not awaited here. + void Promise.resolve().then(() => this.source.return?.()).catch(() => undefined) + } + return Promise.resolve(UPLINK_DONE) + } + + private finish(): void { + this.closed = true + this.signal.removeEventListener('abort', this.onAbort) + this.interrupted.resolve(UPLINK_DONE) + } +} + +/** + * The context of one Remote call as the receiving method reads it through + * `this.ctx.invocation`. The uplink is decoded on first use and released when + * the call's downlink finishes. + */ +class GatewayInvocation implements RemoteInvocation { + private decoder: UplinkDecoder | undefined + private taken = false + + /** + * @param request - decoded endpoint and wire arguments. + * @param service - Cordis service key of the receiver. + * @param peer - Peer the call speaks for. + * @param signal - the signal the method observes. + * @param uplink - carrier items and the codec that decodes them. + */ + constructor( + readonly request: RemoteInvocation['request'], + readonly service: string, + readonly peer: PeerScope, + readonly signal: AbortSignal, + private readonly uplink_: UplinkSource, + ) {} + + uplink(): AsyncIterable { + if (this.taken) { + throw new Error(`typert gateway: ${this.uplink_.endpoint}: invocation.uplink() is available once per call`) + } + this.taken = true + const { source, codec, endpoint, abort } = this.uplink_ + this.decoder = new UplinkDecoder(source, codec, endpoint, this.signal, abort) + // The descriptor codec decides what arrives; `In` is the caller's assertion. + return this.decoder as AsyncIterable + } + + /** + * The downlink finished: release the uplink. Unread items are dropped, and a + * carrier iterable the method never took is returned so it stops producing. + * @returns settles once a taken uplink has closed. + */ + async close(): Promise { + if (this.decoder !== undefined) { + await this.decoder.return() + return + } + if (this.taken) return + this.taken = true + // The carrier owns the source iterator; its return() is not awaited for + // the same reason UplinkDecoder.return() does not await it. + void Promise.resolve().then(() => this.uplink_.source[Symbol.asyncIterator]().return?.()).catch(() => undefined) + } +} + function rpcFailure(error: unknown): ConnectionRpcResult { const remote = remoteErrorOf(error) if (remote !== undefined) { diff --git a/packages/api/gateway/src/remote-error-codes.ts b/packages/api/gateway/src/remote-error-codes.ts index 22f94749f5..5e560557cc 100644 --- a/packages/api/gateway/src/remote-error-codes.ts +++ b/packages/api/gateway/src/remote-error-codes.ts @@ -27,9 +27,11 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'gateway/lookup-not-found': TypertGatewayFaultDetails 'gateway/lookup-unavailable': TypertGatewayFaultDetails 'gateway/method-unavailable': TypertGatewayFaultDetails + 'gateway/protocol': TypertGatewayFaultDetails 'gateway/provider-mismatch': TypertGatewayFaultDetails 'gateway/result-invalid': TypertGatewayFaultDetails 'gateway/service-unavailable': TypertGatewayFaultDetails 'gateway/signature-invalid': TypertGatewayFaultDetails + 'gateway/uplink-overflow': TypertGatewayFaultDetails } } diff --git a/packages/api/gateway/src/stream-protocol.ts b/packages/api/gateway/src/stream-protocol.ts index 5b77944a50..a66bdfe137 100644 --- a/packages/api/gateway/src/stream-protocol.ts +++ b/packages/api/gateway/src/stream-protocol.ts @@ -239,7 +239,7 @@ export function isRemoteEventAgentId(value: unknown): value is RemoteEventAgentI return typeof value === 'string' && value.length > 0 } -/** One logical stream request sent from the browser. */ +/** One logical stream request sent from the browser: open, one uplink item, uplink half-close, or cancel. */ export type RemoteStreamClientMessage = | { readonly type: 'open' @@ -247,6 +247,8 @@ export type RemoteStreamClientMessage = readonly endpoint: string readonly payload: unknown } + | { readonly type: 'item'; readonly streamId: string; readonly value?: unknown } + | { readonly type: 'end'; readonly streamId: string } | { readonly type: 'cancel'; readonly streamId: string } /** Carrier-safe failure delivered by the Host. */ @@ -269,7 +271,15 @@ export type RemoteStreamServerMessage = */ export function parseRemoteStreamClientMessage(text: string): RemoteStreamClientMessage { return parseMessage(text, (value) => { - if (value.type === 'cancel' && exactKeys(value, ['type', 'streamId']) && validId(value.streamId)) { + if ((value.type === 'cancel' || value.type === 'end') + && exactKeys(value, ['type', 'streamId']) + && validId(value.streamId)) { + return value as RemoteStreamClientMessage + } + if (value.type === 'item' + && (exactKeys(value, ['type', 'streamId']) || exactKeys(value, ['type', 'streamId', 'value'])) + && validId(value.streamId) + && (!Object.hasOwn(value, 'value') || isRemoteJsonValue(value.value))) { return value as RemoteStreamClientMessage } if (value.type === 'open' diff --git a/packages/api/gateway/src/stream-server.ts b/packages/api/gateway/src/stream-server.ts index 3a36e97ecd..bf754c6a92 100644 --- a/packages/api/gateway/src/stream-server.ts +++ b/packages/api/gateway/src/stream-server.ts @@ -2,18 +2,36 @@ import type { IncomingMessage } from 'node:http' import type { Duplex } from 'node:stream' +import { Deque } from '@deepseek-ai/dsh-deque' +import { RemoteError, remoteErrorOf, type PeerScope } from '@deepseek-ai/dsh-typert-protocol' import WebSocket, { WebSocketServer, type RawData } from 'ws' import { parseRemoteStreamClientMessage, + type RemoteStreamClientMessage, type RemoteStreamFailure, type RemoteStreamServerMessage, } from './stream-protocol.ts' -/** Open one validated Remote stream for a decoded wire request. */ +/** + * Open one validated Remote stream for a decoded wire request on behalf of one + * Peer. `uplink` carries the Client's items; `control` cancels the logical + * stream, and aborting it with a Remote failure as the reason delivers that + * failure to the Client. + */ export type RemoteStreamOpener = ( endpoint: string, payload: unknown, - signal: AbortSignal, + uplink: AsyncIterable, + peer: PeerScope, + control: AbortController, +) => Promise> + +/** The opener one socket uses: its Peer is fixed at upgrade time. */ +type BoundStreamOpener = ( + endpoint: string, + payload: unknown, + uplink: AsyncIterable, + control: AbortController, ) => Promise> /** Convert an invocation or carrier failure to a stable wire value. */ @@ -32,28 +50,42 @@ export class RemoteStreamMuxServer { * @param open - Gateway stream dispatcher. * @param failure - Gateway error-to-wire mapper. * @param heartbeatIntervalMs - interval between WebSocket Ping control frames. + * @param streamInboxBytes - buffered uplink frame bytes one logical stream may hold before it fails. */ constructor( private readonly open: RemoteStreamOpener, private readonly failure: RemoteStreamFailureMapper, private readonly heartbeatIntervalMs: number, + private readonly streamInboxBytes: number, ) {} /** - * Upgrade one trusted request and begin serving its logical streams. + * Upgrade one admitted request and begin serving its logical streams. Every + * stream the socket opens speaks for the Peer admitted at upgrade, and the + * socket closes when that Peer's scope is disposed. * @param req - authenticated HTTP upgrade request. * @param socket - carrier socket transferred to the WebSocket server. * @param head - bytes already read after the HTTP upgrade headers. + * @param peer - Peer the upgrade was admitted as. */ - handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void { + handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer, peer: PeerScope): void { this.server.handleUpgrade(req, socket, head, (websocket) => { this.missedHeartbeats.set(websocket, 0) websocket.on('pong', () => { this.missedHeartbeats.set(websocket, 0) }) this.startHeartbeat() - const connection = new RemoteStreamMuxConnection(websocket, this.open, this.failure) + const bound: BoundStreamOpener = (endpoint, payload, uplink, control) => + this.open(endpoint, payload, uplink, peer, control) + const connection = new RemoteStreamMuxConnection(websocket, bound, this.failure, this.streamInboxBytes) + const release = peer.ctx.effect( + () => () => { websocket.close(1001, 'peer left') }, + 'api-gateway: Remote stream socket bound to its Peer', + ) const done = connection.run() this.connections.add(done) - void done.then(() => { this.connections.delete(done) }) + void done.then(() => { + this.connections.delete(done) + void release() + }) }) } @@ -96,6 +128,9 @@ export class RemoteStreamMuxServer { interface ActiveStream { readonly abort: AbortController + readonly inbox: UplinkInbox + /** Cancel the logical stream and end any Host read still waiting on its uplink. */ + readonly stop: (reason: Error) => void done: Promise } @@ -105,8 +140,9 @@ class RemoteStreamMuxConnection { constructor( private readonly socket: WebSocket, - private readonly open: RemoteStreamOpener, + private readonly open: BoundStreamOpener, private readonly failure: RemoteStreamFailureMapper, + private readonly streamInboxBytes: number, ) {} async run(): Promise { @@ -127,22 +163,61 @@ class RemoteStreamMuxConnection { }) await closed const active = [...this.streams.values()] - for (const stream of active) stream.abort.abort(new Error('Remote stream socket closed')) + for (const stream of active) stream.stop(new Error('Remote stream socket closed')) await Promise.all(active.map(stream => stream.done)) } private receive(text: string): void { const message = parseRemoteStreamClientMessage(text) - if (message.type === 'cancel') { - this.streams.get(message.streamId)?.abort.abort(new Error('Remote stream cancelled')) - return + switch (message.type) { + case 'open': { + this.openStream(message) + return + } + case 'item': { + this.requireStream(message.streamId).inbox.push(message.value, Buffer.byteLength(text, 'utf8')) + return + } + case 'end': { + this.requireStream(message.streamId).inbox.end() + return + } + case 'cancel': { + this.streams.get(message.streamId)?.stop(new Error('Remote stream cancelled')) + return + } + /* v8 ignore next 4 -- parseRemoteStreamClientMessage admits only the four frame types above. */ + default: { + const unknown: never = message + throw new Error(`api gateway: unknown Remote stream client message ${JSON.stringify(unknown)}`) + } } + } + + /** Uplink frames for an id this connection does not own are a protocol violation. */ + private requireStream(streamId: string): ActiveStream { + const active = this.streams.get(streamId) + if (active === undefined) { + throw new Error(`api gateway: unknown Remote stream id ${JSON.stringify(streamId)}`) + } + return active + } + + private openStream(message: Extract): void { if (this.streams.has(message.streamId)) { throw new Error(`api gateway: duplicate Remote stream id ${JSON.stringify(message.streamId)}`) } const abort = new AbortController() + // Created before the opener resolves so items the Client sends right + // after `open` wait in the inbox instead of being lost. + const inbox = new UplinkInbox(this.streamInboxBytes, message.endpoint, (error) => { abort.abort(error) }) const active: ActiveStream = { abort, + inbox, + stop: (reason) => { + abort.abort(reason) + inbox.fail(reason) + }, done: Promise.resolve(), } this.streams.set(message.streamId, active) @@ -158,22 +233,46 @@ class RemoteStreamMuxConnection { payload: unknown, active: ActiveStream, ): Promise { + let outcome: { readonly failed: false } | { readonly failed: true; readonly error: unknown } try { - const source = await this.open(endpoint, payload, active.abort.signal) + const source = await this.open(endpoint, payload, active.inbox, active.abort) for await (const value of source) { await this.send({ type: 'item', streamId, value }) } - if (!active.abort.signal.aborted) await this.send({ type: 'end', streamId }) + outcome = { failed: false } } catch (error) { - if (!active.abort.signal.aborted && this.socket.readyState === WebSocket.OPEN) { - try { - await this.send({ type: 'error', streamId, error: this.failure(error) }) - } catch { - // A terminal frame that cannot be encoded or written leaves the - // logical stream ambiguous, so fail the physical generation. - this.socket.close(1011, 'Remote stream failure could not be delivered') - } - } + outcome = { failed: true, error } + } + // The downlink has settled; later uplink frames cannot change the outcome. + active.inbox.fail(new Error('Remote stream ended')) + if (active.abort.signal.aborted) { + // A Remote failure as the abort reason is the Gateway or this mux failing + // the stream (a rejected or overflowing uplink item, or an item after + // end); the Client is still waiting for that terminal frame. Any other + // abort is a cancellation. + const reason: unknown = active.abort.signal.reason + if (remoteErrorOf(reason) !== undefined) await this.sendFailure(streamId, reason) + return + } + if (outcome.failed) { + await this.sendFailure(streamId, outcome.error) + return + } + try { + await this.send({ type: 'end', streamId }) + } catch (error) { + await this.sendFailure(streamId, error) + } + } + + private async sendFailure(streamId: string, error: unknown): Promise { + if (this.socket.readyState !== WebSocket.OPEN) return + try { + await this.send({ type: 'error', streamId, error: this.failure(error) }) + } catch { + // A terminal frame that cannot be encoded or written leaves the + // logical stream ambiguous, so fail the physical generation. + this.socket.close(1011, 'Remote stream failure could not be delivered') } } @@ -199,6 +298,115 @@ class RemoteStreamMuxConnection { } } +interface UplinkEntry { + readonly value: unknown + readonly bytes: number +} + +const UPLINK_DONE: IteratorReturnResult = { value: undefined, done: true } + +/** + * Bounded single-consumer uplink queue of one logical stream, the source the + * Host method reads through `invocation.uplink()`. Buffered frame bytes are + * capped: overflow, and an item after the Client's `end`, fail the queue and + * report a Remote failure that the connection uses to fail the logical stream. + */ +class UplinkInbox implements AsyncIterable, AsyncIterator { + private readonly queue = new Deque() + private bytes = 0 + private ended = false + private closed = false + private taken = false + private failure: Error | undefined + private wake: (() => void) | undefined + + constructor( + private readonly maxBytes: number, + private readonly endpoint: string, + private readonly onViolation: (error: RemoteError<'gateway/protocol' | 'gateway/uplink-overflow'>) => void, + ) {} + + push(value: unknown, frameBytes: number): void { + if (this.failure !== undefined || this.closed) return + if (this.ended) { + this.violate(new RemoteError( + 'gateway/protocol', + 'api gateway: Remote stream uplink item after end', + { endpoint: this.endpoint }, + )) + return + } + if (this.bytes + frameBytes > this.maxBytes) { + this.violate(new RemoteError( + 'gateway/uplink-overflow', + `api gateway: Remote stream uplink exceeded ${String(this.maxBytes)} buffered bytes`, + { endpoint: this.endpoint }, + )) + return + } + this.queue.pushBack({ value, bytes: frameBytes }) + this.bytes += frameBytes + this.signal() + } + + /** Client half-close; idempotent. */ + end(): void { + if (this.ended) return + this.ended = true + this.signal() + } + + /** End the consumer's next read with `error`; idempotent, drops buffered items. */ + fail(error: Error): void { + if (this.failure !== undefined) return + this.failure = error + this.queue.clear() + this.bytes = 0 + this.signal() + } + + [Symbol.asyncIterator](): AsyncIterator { + if (this.taken) throw new Error('api gateway: Remote stream uplink inbox already has a consumer') + this.taken = true + return this + } + + async next(): Promise> { + while (true) { + if (this.closed) return UPLINK_DONE + const entry = this.queue.popFront() + if (entry !== undefined) { + this.bytes -= entry.bytes + return { value: entry.value, done: false } + } + if (this.failure !== undefined) throw this.failure + if (this.ended) return UPLINK_DONE + if (this.wake !== undefined) throw new Error('api gateway: Remote stream uplink inbox has one pending read') + await new Promise((resolve) => { this.wake = resolve }) + } + } + + /** Consumer stopped reading: later items are dropped, a pending read ends. */ + return(): Promise> { + this.closed = true + this.queue.clear() + this.bytes = 0 + this.signal() + return Promise.resolve(UPLINK_DONE) + } + + private violate(error: RemoteError<'gateway/protocol' | 'gateway/uplink-overflow'>): void { + this.fail(error) + this.onViolation(error) + } + + private signal(): void { + const wake = this.wake + this.wake = undefined + wake?.() + } +} + function rawText(data: RawData): string { if (Array.isArray(data)) return Buffer.concat(data).toString('utf8') if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8') diff --git a/packages/api/gateway/src/types.ts b/packages/api/gateway/src/types.ts index f325d46e1a..3176da05ae 100644 --- a/packages/api/gateway/src/types.ts +++ b/packages/api/gateway/src/types.ts @@ -4,6 +4,7 @@ */ import type { Context } from '@deepseek-ai/cordis' +import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol' import type { RemoteEventHostInfo } from './stream-protocol.ts' /** One Remote method request after a carrier has decoded its envelope. */ @@ -14,6 +15,13 @@ export interface InvokeRemoteRequest { readonly method: string /** Named wire values; fields must exactly match the descriptor. */ readonly args: Readonly> + /** + * Client uplink items of this logical stream, delivered to the method through + * `invocation.uplink()`; absent means an immediately ended iterable. + */ + readonly uplink?: AsyncIterable + /** Peer the call speaks for; absent means an in-process carrier, answered as the operator. */ + readonly peer?: PeerScope /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ readonly signal?: AbortSignal } @@ -78,12 +86,16 @@ export interface TypertGatewayWireStream { * Open one logical stream from its wire endpoint and payload. * @param endpoint - canonical Remote endpoint or Gateway-owned stream name. * @param payload - decoded carrier payload. + * @param uplink - Client-to-Host items of the logical stream; Gateway-owned endpoints ignore them. + * @param peer - Peer the stream speaks for; `undefined` means the operator's in-process carrier. * @param signal - logical-stream cancellation. * @returns validated stream values. */ readonly open: ( endpoint: string, payload: unknown, + uplink: AsyncIterable, + peer: PeerScope | undefined, signal: AbortSignal, ) => Promise> @@ -114,10 +126,12 @@ export type TypertGatewayErrorCode = | 'gateway/lookup-not-found' | 'gateway/lookup-unavailable' | 'gateway/method-unavailable' + | 'gateway/protocol' | 'gateway/provider-mismatch' | 'gateway/result-invalid' | 'gateway/service-unavailable' | 'gateway/signature-invalid' + | 'gateway/uplink-overflow' /** Host dispatcher consumed by Connection adapters. */ export interface TypertGateway { @@ -145,7 +159,7 @@ export interface TypertGateway { /** * Open one live stream Remote method without assuming a physical carrier. - * @param request - decoded endpoint and named wire arguments. + * @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one. * @returns a cancellation-aware iterable over the business results. */ stream(request: InvokeRemoteRequest): Promise> diff --git a/packages/api/gateway/tests/browser-credentials.ts b/packages/api/gateway/tests/browser-credentials.ts index 8661983ea8..9bced52acc 100644 --- a/packages/api/gateway/tests/browser-credentials.ts +++ b/packages/api/gateway/tests/browser-credentials.ts @@ -1,5 +1,7 @@ import type { Context } from '@deepseek-ai/cordis' +const browserCookies = new WeakMap() + /** Provide an in-memory credential-record owner for a mounted Connection plugin. */ export function provideBrowserCredentials(ctx: Context): void { const records = new Map() @@ -15,3 +17,28 @@ export function provideBrowserCredentials(ctx: Context): void { }, } as never) } + +/** + * Exchange a test Host's process token for its WebSocket/HTTP Cookie header. + * @param ctx - Host root with the Web server and Connection mounted. + * @returns the `cookie` header value, cached per root. + */ +export function browserCookie(ctx: Context): string { + const existing = browserCookies.get(ctx) + if (existing !== undefined) return existing + const origin = `http://127.0.0.1:${String(ctx.webServer.port)}` + const target = new URL(ctx.connection.authenticatedUrl(origin)) + let setCookie: string | undefined + ctx.connection.authorizeIndex({ + method: 'GET', + url: `${target.pathname}${target.search}`, + headers: { host: target.host }, + }, { + writeHead(_status, headers) { setCookie = headers?.['set-cookie'] }, + end() {}, + }) + if (setCookie === undefined) throw new Error('gateway fixture did not receive a browser cookie') + const cookie = setCookie.split(';', 1)[0]! + browserCookies.set(ctx, cookie) + return cookie +} diff --git a/packages/api/gateway/tests/echo-stream.host.spec.ts b/packages/api/gateway/tests/echo-stream.host.spec.ts new file mode 100644 index 0000000000..d56b529002 --- /dev/null +++ b/packages/api/gateway/tests/echo-stream.host.spec.ts @@ -0,0 +1,96 @@ +import { once } from 'node:events' +import { afterEach, describe, expect, it, vi } from 'vitest' +import WebSocket, { type RawData } from 'ws' +import { Context } from '@deepseek-ai/cordis' +import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' +import WebServer from '@deepseek-ai/dsh-host-webserver' +import { Remote, TypertRemoteService, type RemoteStream } from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import TypertGatewayService from '@deepseek-ai/dsh-api-gateway' +import { browserCookie, provideBrowserCredentials } from './browser-credentials.ts' + +/** The smallest Remote stream that reads its uplink: every carrier has to serve it alike. */ +class EchoService extends TypertRemoteService { + constructor(ctx: Context) { + super(ctx, 'echo', { namespace: 'echo' }) + } + + @Remote({ mode: 'stream' }) + async *echo(prefix: string, signal: AbortSignal): RemoteStream { + const invocation = this.ctx.invocation + if (invocation === undefined) throw new Error('echo ran outside a Remote call') + for await (const item of invocation.uplink()) { + signal.throwIfAborted() + yield `${prefix}${item}` + } + } +} + +const roots: Context[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +describe('the echo stream on each carrier', () => { + it('echoes uplink items through the in-process carrier', async () => { + const ctx = await setup(false) + const replies = await ctx.typertGateway.stream({ + namespace: 'echo', method: 'echo', args: { prefix: '> ' }, uplink: items(['a', 'b']), + }) + await expect(collect(replies)).resolves.toEqual(['> a', '> b']) + }) + + it('echoes uplink frames through the WebSocket carrier', async () => { + const ctx = await setup(true) + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { + headers: { cookie: browserCookie(ctx) }, + }) + await once(socket, 'open') + const frames: unknown[] = [] + socket.on('message', (data) => { frames.push(JSON.parse(rawText(data))) }) + socket.send(JSON.stringify({ type: 'open', streamId: 'echo', endpoint: 'echo/echo', payload: { args: { prefix: '> ' } } })) + socket.send(JSON.stringify({ type: 'item', streamId: 'echo', value: 'a' })) + socket.send(JSON.stringify({ type: 'item', streamId: 'echo', value: 'b' })) + socket.send(JSON.stringify({ type: 'end', streamId: 'echo' })) + await vi.waitFor(() => { + expect(frames).toEqual([ + { type: 'item', streamId: 'echo', value: '> a' }, + { type: 'item', streamId: 'echo', value: '> b' }, + { type: 'end', streamId: 'echo' }, + ]) + }) + socket.close() + await once(socket, 'close') + }) +}) + +async function setup(transport: boolean): Promise { + const ctx = new Context() + roots.push(ctx) + if (transport) { + await ctx.plugin(WebServer, { host: '127.0.0.1', port: 0 }) + provideBrowserCredentials(ctx) + } + await ctx.plugin(TypertRegistry) + await ctx.plugin(TypertGatewayService, {}) + if (transport) await ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) + await ctx.plugin(EchoService) + return ctx +} + +async function *items(values: readonly T[]): AsyncIterable { + for (const value of values) yield value +} + +async function collect(source: AsyncIterable): Promise { + const values: unknown[] = [] + for await (const value of source) values.push(value) + return values +} + +function rawText(data: RawData): string { + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8') + if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8') + return Buffer.from(data).toString('utf8') +} diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index 904dfb84a9..bb1b5ff08a 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -9,7 +9,11 @@ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { bindTypertRemote, Remote, + remoteErrorOf, type InvocationDescriptor, + type PeerScope, + type RemoteInvocation, + type RemoteStream, type TypertContextMap, type TypertContextWire, RemoteError, @@ -22,7 +26,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'fixture/broken': { readonly count: bigint } } } -import { provideBrowserCredentials } from './browser-credentials.ts' +import { browserCookie, provideBrowserCredentials } from './browser-credentials.ts' import TypertGatewayService, { TypertGatewayError, type Config as GatewayConfig, @@ -42,35 +46,16 @@ vi.mock('node:crypto', async (importOriginal) => { }) const randomUuid = vi.mocked(randomUUID) -const browserCookies = new WeakMap() const REMOTE_HOST = { home: '/home/fixture' } as const type AgentWireId = TypertContextWire const agentId = (value: string): AgentWireId => value as AgentWireId -/** Exchange this test Host's process token for its WebSocket/HTTP Cookie header. */ -function browserCookie(ctx: Context): string { - const existing = browserCookies.get(ctx) - if (existing !== undefined) return existing - const origin = `http://127.0.0.1:${String(ctx.webServer.port)}` - const target = new URL(ctx.connection.authenticatedUrl(origin)) - let setCookie: string | undefined - ctx.connection.authorizeIndex({ - method: 'GET', - url: `${target.pathname}${target.search}`, - headers: { host: target.host }, - }, { - writeHead(_status, headers) { setCookie = headers?.['set-cookie'] }, - end() {}, - }) - if (setCookie === undefined) throw new Error('gateway stream fixture did not receive a browser cookie') - const cookie = setCookie.split(';', 1)[0]! - browserCookies.set(ctx, cookie) - return cookie -} - class FeedService extends Service { readonly typertRemote = bindTypertRemote(this, 'feed') readonly signals: AbortSignal[] = [] + readonly peeked: unknown[] = [] + readonly peers: PeerScope[] = [] + leftover: AsyncIterator | undefined returns = 0 constructor(ctx: Context) { @@ -133,9 +118,98 @@ class FeedService extends Service { throw new RemoteError('fixture/broken', 'fixture emitted invalid details', { count: 1n }) } + @Remote({ mode: 'stream' }) + async *echo(prefix: string, signal: AbortSignal): RemoteStream { + this.signals.push(signal) + const invocation = this.invocation() + this.peers.push(invocation.peer) + try { + for await (const item of invocation.uplink()) yield `${prefix}${item}` + } finally { + this.returns += 1 + } + } + + /** Hides the uplink failure so the test proves the Gateway still fails the stream. */ + @Remote({ mode: 'stream' }) + async *swallow(signal: AbortSignal): RemoteStream { + try { + for await (const item of this.invocation().uplink()) yield item + } catch { + // The fixture keeps yielding after the rejected item on purpose. + } + yield 'swallowed' + await abortOf(signal) + } + + /** Never takes its uplink. */ + @Remote({ mode: 'stream' }) + async *ignore(prefix: string): RemoteStream { + yield `${prefix}:one` + yield `${prefix}:two` + } + + /** Keeps the downlink open without reading, and retains the uplink iterator for post-stream reads. */ + @Remote({ mode: 'stream' }) + async *hold(signal: AbortSignal): RemoteStream { + this.leftover = this.invocation().uplink()[Symbol.asyncIterator]() + yield 'held' + await abortOf(signal) + } + + /** Reads before yielding so cancellation races the pending read, then reads once more after the abort. */ + @Remote({ mode: 'stream' }) + async *peek(signal: AbortSignal): RemoteStream { + const iterator = this.invocation().uplink()[Symbol.asyncIterator]() + const pending = iterator.next().then(result => result, (error: unknown) => error) + yield 'ready' + await abortOf(signal) + this.peeked.push(await pending, await iterator.next().then(result => result, (error: unknown) => error)) + } + + /** Reports the call context; its descriptor declares no uplink codec, so items arrive as `unknown`. */ + @Remote({ mode: 'stream' }) + async *context(label: string): RemoteStream { + const invocation = this.invocation() + this.peers.push(invocation.peer) + const { namespace, method, args } = invocation.request + yield `${namespace}/${method}:${invocation.service}:${JSON.stringify(args)}:${label}` + for await (const item of invocation.uplink()) yield `raw:${JSON.stringify(item)}` + try { + invocation.uplink() + } catch (error) { + yield error instanceof Error ? error.message : String(error) + } + } + + /** SRC-discovered: no descriptor, so uplink items arrive as `unknown`. */ + @Remote({ mode: 'stream' }) + async *srcEcho(prefix: string, signal: AbortSignal): RemoteStream { + void signal + for await (const item of this.invocation().uplink()) yield `${prefix}${String(item)}` + } + unary(label: string): string { return label } + + /** Reads the uplink inside a unary call: items arrive only while the method runs. */ + async unaryUplink(): Promise { + const items: string[] = [] + for await (const item of this.invocation().uplink()) items.push(item) + return items + } + + /** What `ctx.invocation` reads on the Service's own Context, outside any Remote call. */ + invocationOutsideCall(): RemoteInvocation | undefined { + return this.ctx.invocation + } + + private invocation(): RemoteInvocation { + const invocation = this.ctx.invocation + if (invocation === undefined) throw new Error('fixture method ran outside a Remote call') + return invocation + } } const roots: Context[] = [] @@ -218,13 +292,16 @@ afterEach(async () => { }) describe('Typert Remote streams', () => { - it('validates the WebSocket heartbeat timer range', () => { - expect(TypertGatewayService.Config({})).toEqual({ websocketHeartbeatIntervalMs: 2_000 }) - expect(TypertGatewayService.Config({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS })) - .toEqual({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS }) + it('validates the WebSocket heartbeat timer range and the stream inbox bound', () => { + expect(TypertGatewayService.Config({})).toEqual({ websocketHeartbeatIntervalMs: 2_000, streamInboxBytes: 262_144 }) + expect(TypertGatewayService.Config({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS, streamInboxBytes: 1 })) + .toEqual({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS, streamInboxBytes: 1 }) for (const websocketHeartbeatIntervalMs of [0, 1.5, MAX_TIMER_DELAY_MS + 1]) { expect(() => TypertGatewayService.Config({ websocketHeartbeatIntervalMs })).toThrow() } + for (const streamInboxBytes of [0, 1.5]) { + expect(() => TypertGatewayService.Config({ streamInboxBytes })).toThrow() + } }) it('opens decoded carrier payloads through the in-process wire adapter', async () => { @@ -232,10 +309,214 @@ describe('Typert Remote streams', () => { const source = await ctx.typertGateway.wireStream.open( 'feed/sync', { args: { label: 'wire' } }, + toAsync([]), + undefined, new AbortController().signal, ) - await expect(collect(source)).resolves.toEqual(['wire:one', 'wire:two']) + + const echoed = await ctx.typertGateway.wireStream.open( + 'feed/echo', + { args: { prefix: 'wire:' } }, + toAsync(['a', 'b']), + undefined, + new AbortController().signal, + ) + await expect(collect(echoed)).resolves.toEqual(['wire:a', 'wire:b']) + }) + + it('delivers uplink items through the in-process carrier and ends on half-close', async () => { + const { ctx, service } = await setup(false) + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'echo', args: { prefix: '> ' }, uplink: toAsync(['a', 'b']), + }))).resolves.toEqual(['> a', '> b']) + expect(service.returns).toBe(1) + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'echo', args: { prefix: 'x' }, + }))).resolves.toEqual([]) + await expect(ctx.typertGateway.invoke({ + namespace: 'feed', method: 'echo', args: { prefix: 'x' }, + })).rejects.toMatchObject({ + code: 'gateway/signature-invalid', + message: 'typert gateway: feed/echo: stream Remote methods must be opened through the stream carrier', + }) + + // The caller's iterator is released even when its own return() rejects. + const returned = vi.fn(async (): Promise> => { + throw new Error('fixture release failure') + }) + const unread: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + next: () => new Promise>(() => {}), + return: returned, + }), + } + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'ignore', args: { prefix: 'i' }, uplink: unread, + }))).resolves.toEqual(['i:one', 'i:two']) + await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) + }) + + it('fails a stream whose uplink item fails its codec, even when the method swallows the failure', async () => { + const { ctx, service } = await setup(false) + const rejected = await ctx.typertGateway.stream({ + namespace: 'feed', method: 'echo', args: { prefix: '' }, uplink: toAsync(['ok', 1]), + }) + const iterator = rejected[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'ok' }) + await expect(iterator.next()).rejects.toMatchObject({ + code: 'gateway/input-invalid', + details: { endpoint: 'feed/echo', field: 'uplink' }, + }) + const signal = service.signals.at(-1) + expect(signal?.aborted).toBe(true) + expect(remoteErrorOf(signal?.reason)?.code).toBe('gateway/input-invalid') + expect(service.returns).toBe(1) + + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'swallow', args: {}, uplink: toAsync([1]), + }))).rejects.toMatchObject({ code: 'gateway/input-invalid' }) + }) + + it('ends a pending uplink read on cancellation and reports later reads as cancelled', async () => { + const { ctx, service } = await setup(false) + const abort = new AbortController() + const source = await ctx.typertGateway.stream({ + namespace: 'feed', method: 'peek', args: {}, uplink: neverYielding(), signal: abort.signal, + }) + const iterator = source[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'ready' }) + const pending = iterator.next() + abort.abort(new Error('fixture cancellation')) + await expect(pending).rejects.toThrow('Remote invocation "feed/peek" was aborted') + await vi.waitFor(() => { expect(service.peeked).toHaveLength(2) }) + expect(service.peeked.map(outcome => remoteErrorOf(outcome)?.code)) + .toEqual(['gateway/cancelled', 'gateway/cancelled']) + }) + + it('drops unread uplink items once the method finishes', async () => { + const { ctx, service } = await setup(false) + const abort = new AbortController() + const source = await ctx.typertGateway.stream({ + namespace: 'feed', method: 'hold', args: {}, uplink: toAsync(['unread']), signal: abort.signal, + }) + const iterator = source[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'held' }) + const pending = iterator.next() + abort.abort(new Error('fixture cancellation')) + await expect(pending).rejects.toThrow('Remote invocation "feed/hold" was aborted') + const leftover = service.leftover + if (leftover === undefined) throw new Error('fixture did not retain its uplink iterator') + await expect(leftover.next()).resolves.toEqual({ done: true, value: undefined }) + await expect(leftover.return?.()).resolves.toEqual({ done: true, value: undefined }) + }) + + it('exposes the call as ctx.invocation, delivers codec-less uplink items as unknown, and hands the uplink out once', async () => { + const { ctx, service } = await setup(false) + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'context', args: { label: 'ctx' }, uplink: toAsync([1, { nested: true }]), + }))).resolves.toEqual([ + 'feed/context:feed:{"label":"ctx"}:ctx', + 'raw:1', + 'raw:{"nested":true}', + 'typert gateway: feed/context: invocation.uplink() is available once per call', + ]) + expect(service.peers.at(-1)?.id).toBe('in-process-operator') + expect(service.invocationOutsideCall()).toBeUndefined() + + // Items sent to a method that never takes its uplink wait in the carrier and are dropped when the downlink ends. + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'sync', args: { label: 'closed' }, uplink: toAsync(['unread']), + }))).resolves.toEqual(['closed:one', 'closed:two']) + + // Without a codec an item still has to be a JSON-safe value. + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'context', args: { label: 'bad' }, uplink: toAsync([1n]), + }))).rejects.toMatchObject({ + code: 'gateway/input-invalid', + details: { endpoint: 'feed/context', field: 'uplink' }, + }) + }) + + it('lets a unary method read uplink items while it runs', async () => { + const { ctx } = await setup(false) + await expect(ctx.typertGateway.invoke({ + namespace: 'feed', method: 'unaryUplink', args: {}, uplink: toAsync(['a', 'b']), + })).resolves.toEqual(['a', 'b']) + }) + + it('derives SRC descriptors whose uplink arrives without a codec', async () => { + const { ctx } = await setup(false) + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'srcEcho', args: { prefix: 's:' }, uplink: toAsync(['a', 2]), + }))).resolves.toEqual(['s:a', 's:2']) + }) + + it('echoes uplink frames over the WebSocket carrier and fails misused uplinks per stream', async () => { + const { ctx, service } = await setup(true) + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { + headers: { cookie: browserCookie(ctx) }, + }) + await once(socket, 'open') + const frames: Record[] = [] + socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) + + sendOpen(socket, 'echo', 'feed/echo', { prefix: '> ' }) + socket.send(JSON.stringify({ type: 'item', streamId: 'echo', value: 'a' })) + socket.send(JSON.stringify({ type: 'item', streamId: 'echo', value: 'b' })) + socket.send(JSON.stringify({ type: 'end', streamId: 'echo' })) + await vi.waitFor(() => { + expect(frames.filter(frame => frame.streamId === 'echo')).toEqual([ + { type: 'item', streamId: 'echo', value: '> a' }, + { type: 'item', streamId: 'echo', value: '> b' }, + { type: 'end', streamId: 'echo' }, + ]) + }) + expect(service.returns).toBe(1) + expect(service.peers.at(-1)).toBe(ctx.connection.peers.operator) + + sendOpen(socket, 'rejected', 'feed/echo', { prefix: '' }) + socket.send(JSON.stringify({ type: 'item', streamId: 'rejected', value: 1 })) + await vi.waitFor(() => { + expect(frames.find(frame => frame.streamId === 'rejected')).toMatchObject({ + type: 'error', + error: { code: 'gateway/input-invalid', details: { endpoint: 'feed/echo', field: 'uplink' } }, + }) + }) + + sendOpen(socket, 'late', 'feed/hold', {}) + await vi.waitFor(() => { expect(frames).toContainEqual({ type: 'item', streamId: 'late', value: 'held' }) }) + socket.send(JSON.stringify({ type: 'end', streamId: 'late' })) + socket.send(JSON.stringify({ type: 'item', streamId: 'late', value: 'after end' })) + await vi.waitFor(() => { + expect(frames.find(frame => frame.streamId === 'late' && frame.type === 'error')).toMatchObject({ + error: { code: 'gateway/protocol', details: { endpoint: 'feed/hold' } }, + }) + }) + expect(socket.readyState).toBe(WebSocket.OPEN) + socket.close() + await once(socket, 'close') + }) + + it('fails a stream whose buffered uplink exceeds the configured inbox bytes', async () => { + const { ctx } = await setup(true, { streamInboxBytes: 64 }) + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { + headers: { cookie: browserCookie(ctx) }, + }) + await once(socket, 'open') + const frames: Record[] = [] + socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) + + sendOpen(socket, 'big', 'feed/hold', {}) + await vi.waitFor(() => { expect(frames).toContainEqual({ type: 'item', streamId: 'big', value: 'held' }) }) + socket.send(JSON.stringify({ type: 'item', streamId: 'big', value: 'x'.repeat(128) })) + await vi.waitFor(() => { + expect(frames.find(frame => frame.streamId === 'big' && frame.type === 'error')).toMatchObject({ + error: { code: 'gateway/uplink-overflow', details: { endpoint: 'feed/hold' } }, + }) + }) + socket.close() + await once(socket, 'close') }) it('passes Iterable and AsyncIterable items through and returns the iterator on cancellation', async () => { @@ -603,6 +884,8 @@ describe('Typert Remote streams', () => { it('retries a colliding Remote event Client id before opening the second generation', async () => { const { ctx } = await setup(true) + // Connection minted the operator Peer id above; only the event Client ids are counted below. + randomUuid.mockClear() const source = new RemoteEventSourceProbe() const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const firstId = '00000000-0000-4000-8000-000000000011' as ReturnType @@ -956,6 +1239,7 @@ function descriptors(): InvocationDescriptor[] { source: 'json' as const, codec: { mode: 'strict' as const, typeSymbol: '@fixture/feed#Label', create: () => z.string() }, } + const prefix = { ...label, name: 'prefix', wire: 'prefix' } const stream = (method: string, parameters: InvocationDescriptor['parameters'], schema: z.ZodType): InvocationDescriptor => ({ id: `@fixture/feed#feed/${method}`, service: 'feed', @@ -966,7 +1250,18 @@ function descriptors(): InvocationDescriptor[] { parameters, result: { mode: 'strict', typeSymbol: '@fixture/feed#Item', create: () => schema }, }) + const withUplink = (method: string, parameters: InvocationDescriptor['parameters'], cancellable = true): InvocationDescriptor => ({ + ...stream(method, parameters, z.string()), + uplink: { codec: { mode: 'strict', typeSymbol: '@fixture/feed#Item', create: () => z.string() } }, + ...(cancellable ? { cancellation: { parameter: 'signal' } } : {}), + }) return [ + withUplink('echo', [prefix]), + withUplink('swallow', []), + withUplink('ignore', [prefix], false), + withUplink('hold', []), + withUplink('peek', []), + stream('context', [label], z.string()), { ...stream('follow', [label], z.string()), cancellation: { parameter: 'signal' } }, stream('sync', [label], z.string()), stream('invalid', [], z.string()), @@ -984,6 +1279,15 @@ function descriptors(): InvocationDescriptor[] { parameters: [label], result: { mode: 'strict', typeSymbol: '@fixture/feed#Item', create: () => z.string() }, }, + { + id: '@fixture/feed#feed/unaryUplink', + service: 'feed', + namespace: 'feed', + method: 'unaryUplink', + invocation: { kind: 'direct' }, + parameters: [], + result: { mode: 'strict', typeSymbol: '@fixture/feed#Items', create: () => z.array(z.string()) }, + }, ] } @@ -1089,3 +1393,18 @@ async function collect(source: AsyncIterable): Promise { for await (const value of source) values.push(value) return values } + +async function *toAsync(values: readonly T[]): AsyncIterable { + for (const value of values) yield value +} + +function neverYielding(): AsyncIterable { + return { [Symbol.asyncIterator]: () => ({ next: () => new Promise>(() => {}) }) } +} + +function abortOf(signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) +} diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index 1a1102e5b5..2a729d63a7 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -21,6 +21,7 @@ import type { TypertRemoteScopeApi, TypertRemoteNamespace, } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol/client' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { ClientRemote } from '../src/client/index.ts' import { apply, inject, RemoteStream } from '../src/client/index.ts' @@ -28,6 +29,7 @@ import { RemoteStreamCarrierError, RemoteStreamMuxClient, } from '../src/client/stream-client.ts' +import { parseRemoteStreamClientMessage } from '../src/stream-protocol.ts' type FixtureApprovalOutcome = 'allowed' | 'unavailable' const fixtureContextTag = Symbol('fixture-context-tag') @@ -93,6 +95,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' { ) => Promise> 'probe/maybe': (value: string | null | undefined) => Promise> 'probe/watch': (topic: string, signal?: AbortSignal) => AsyncIterable + 'probe/attach': (topic: string, signal?: AbortSignal) => RemoteStreamHandle } interface TypertRemoteScopeMap { @@ -224,6 +227,28 @@ function streamDescriptor(): InvocationDescriptor { } } +function attachDescriptor(): InvocationDescriptor { + return { + id: '@fixture/probe#probe/attach', + service: 'probe', + namespace: 'probe', + method: 'attach', + mode: 'stream', + invocation: { kind: 'direct' }, + parameters: [{ + name: 'topic', + wire: 'topic', + source: 'json', + codec: { mode: 'strict', typeSymbol: '@fixture#Topic', create: () => z.string().min(1) }, + }], + uplink: { + codec: { mode: 'strict', typeSymbol: '@fixture#AttachInput', create: () => z.string() }, + }, + cancellation: { parameter: 'signal' }, + result: { mode: 'strict', typeSymbol: '@fixture#AttachItem', create: () => z.string().min(1) }, + } +} + type WebSocketGlobal = { WebSocket?: typeof WebSocket } class FakeWebSocket extends EventTarget { @@ -2226,20 +2251,6 @@ describe('Client Typert API', () => { it('guards stream iteration across mount and Connection withdrawal', async () => { const call = vi.fn() const ctx = await bench(call) - const firstDispose = await ctx.remote.$mount({ - package: '@fixture/stream-first', descriptors: [streamDescriptor()], - }) - const withdrawn = ctx.remote.probe.watch('withdrawn')[Symbol.asyncIterator]() - await firstDispose() - await expect(withdrawn.next()).rejects.toThrow('Remote method probe/watch is no longer mounted') - - const secondDispose = await ctx.remote.$mount({ - package: '@fixture/stream-second', descriptors: [streamDescriptor()], - }) - ctx.set('connection', undefined) - await expect(ctx.remote.probe.watch('offline')[Symbol.asyncIterator]().next()) - .rejects.toThrow('probe/watch has no active Connection') - let release!: () => void const released = new Promise((resolve) => { release = resolve }) let markStarted!: () => void @@ -2252,12 +2263,23 @@ describe('Client Typert API', () => { ctx.set('connection', { rpc: { call, open: () => source() }, } as unknown as ConnectionHandle) + const firstDispose = await ctx.remote.$mount({ + package: '@fixture/stream-first', descriptors: [streamDescriptor()], + }) + // The handle opens its stream when the method is called; the mount is withdrawn before the first item arrives. const active = ctx.remote.probe.watch('active')[Symbol.asyncIterator]() const pending = active.next() await started - await secondDispose() + await firstDispose() release() await expect(pending).rejects.toThrow('Remote method probe/watch is no longer mounted') + + const secondDispose = await ctx.remote.$mount({ + package: '@fixture/stream-second', descriptors: [streamDescriptor()], + }) + ctx.set('connection', undefined) + expect(() => ctx.remote.probe.watch('offline')).toThrow('probe/watch has no active Connection') + await secondDispose() }) it('publishes a namespace only after every contributed method is installed', async () => { @@ -2416,6 +2438,163 @@ describe('Client Typert API', () => { else Object.defineProperty(globalThis, 'document', documentDescriptor) } }) + + it('hands the handle uplink to the in-process carrier and echoes it through remote-mock', async () => { + const mock = RemoteMock.create().stream('probe/attach', async (args, stream) => { + const [{ topic }] = args as [{ readonly topic: string }] + for await (const item of stream.uplink) stream.push(`${topic}:${String(item)}`) + stream.end() + }) + const call = vi.fn() + const { ctx, client } = await benchFiber(call, 'in-process', mock.rpc.open) + await expect(ctx.remote.$mount({ + package: '@fixture/weak-uplink', + descriptors: [{ ...attachDescriptor(), uplink: { codec: { mode: 'src-json' } } }], + })).rejects.toThrow('client api: generated Remote probe/attach field "uplink" has no strict codec') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const alpha = ctx.remote.probe.attach('alpha') + alpha.send('a') + alpha.send('b') + alpha.end() + alpha.end() + await expect(drainStream(alpha)).resolves.toEqual(['alpha:a', 'alpha:b']) + expect(() => { alpha.send('late') }).toThrow('client api: probe/attach stream has terminated') + const beta = ctx.remote.probe.attach('beta', new AbortController().signal) + beta.send('c') + expect(() => { beta.end(); beta.send('d') }).toThrow('client api: probe/attach uplink was ended') + await expect(drainStream(beta)).resolves.toEqual(['beta:c']) + expect(mock.log.streams('probe/attach').map(open => open.args)).toEqual([[{ topic: 'alpha' }], [{ topic: 'beta' }]]) + + // Arity is a runtime contract, so the topic is deliberately omitted through Reflect. + expect(() => Reflect.apply(ctx.remote.probe.attach, ctx.remote.probe, [])).toThrow( + 'client api: probe/attach expected 1 business argument(s) plus an optional AbortSignal, got 0', + ) + expect(call).not.toHaveBeenCalled() + } finally { + await dispose() + await client.dispose() + } + }) + + it('sends handle uplink items as wire frames, queued until the open frame, and half-closes on end', async () => { + await withFakeWebSocket('https://harness.example/', async () => { + const ctx = await bench(vi.fn(), 'web') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const handle = ctx.remote.probe.attach('alpha') + handle.send('a') + handle.send('b') + handle.end() + const stream = handle[Symbol.asyncIterator]() + const first = stream.next() + const socket = FakeWebSocket.sockets[0]! + await vi.waitFor(() => { expect(socket.sent).toHaveLength(4) }) + const frames = socket.sent.map(text => parseRemoteStreamClientMessage(text)) + const opened = frames[0] + if (opened?.type !== 'open') throw new Error('fixture expected the open frame first') + expect(opened).toMatchObject({ endpoint: 'probe/attach', payload: { args: { topic: 'alpha' } } }) + expect(frames.slice(1)).toEqual([ + { type: 'item', streamId: opened.streamId, value: 'a' }, + { type: 'item', streamId: opened.streamId, value: 'b' }, + { type: 'end', streamId: opened.streamId }, + ]) + + socket.receive({ type: 'item', streamId: opened.streamId, value: 'alpha:a' }) + await expect(first).resolves.toEqual({ done: false, value: 'alpha:a' }) + const ended = stream.next() + socket.receive({ type: 'end', streamId: opened.streamId }) + await expect(ended).resolves.toEqual({ done: true, value: undefined }) + expect(socket.sent).toHaveLength(4) + expect(() => { handle.send('late') }).toThrow('client api: probe/attach stream has terminated') + handle.end() + expect(socket.sent).toHaveLength(4) + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + }) + + it('terminates the handle uplink when the downlink ends first', async () => { + await withFakeWebSocket('https://harness.example/', async () => { + const ctx = await bench(vi.fn(), 'web') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const handle = ctx.remote.probe.attach('alpha') + const stream = handle[Symbol.asyncIterator]() + const pending = stream.next() + handle.send('first') + const socket = FakeWebSocket.sockets[0]! + await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) }) + const opened = JSON.parse(socket.sent[0]!) as { streamId: string } + socket.receive({ type: 'end', streamId: opened.streamId }) + await expect(pending).resolves.toEqual({ done: true, value: undefined }) + expect(() => { handle.send('second') }).toThrow('client api: probe/attach stream has terminated') + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'item']) + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + }) + + it('sends cancel and ends the downlink quietly when the handle is disposed', async () => { + await withFakeWebSocket('https://harness.example/', async () => { + const ctx = await bench(vi.fn(), 'web') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const handle = ctx.remote.probe.attach('alpha') + const pending = handle[Symbol.asyncIterator]().next() + const socket = FakeWebSocket.sockets[0]! + await vi.waitFor(() => { expect(socket.sent).toHaveLength(1) }) + handle.dispose() + handle.dispose() + await expect(pending).resolves.toEqual({ done: true, value: undefined }) + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'cancel']) + expect(() => { handle.send('after dispose') }).toThrow('client api: probe/attach stream has terminated') + + // Breaking out of the iteration early is the same as dispose(). + const second = ctx.remote.probe.attach('beta') + const iterator = second[Symbol.asyncIterator]() + const opened = iterator.next() + await vi.waitFor(() => { expect(socket.sent).toHaveLength(3) }) + const betaId = (JSON.parse(socket.sent[2]!) as { streamId: string }).streamId + socket.receive({ type: 'item', streamId: betaId, value: 'beta:1' }) + await expect(opened).resolves.toEqual({ done: false, value: 'beta:1' }) + await expect(iterator.return?.()).resolves.toEqual({ done: true, value: undefined }) + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)) + .toEqual(['open', 'cancel', 'open', 'cancel']) + expect(() => { second.send('late') }).toThrow('client api: probe/attach stream has terminated') + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + }) + + it('sends cancel and fails the downlink when the caller signal aborts', async () => { + await withFakeWebSocket('https://harness.example/', async () => { + const ctx = await bench(vi.fn(), 'web') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const abort = new AbortController() + const handle = ctx.remote.probe.attach('alpha', abort.signal) + const pending = handle[Symbol.asyncIterator]().next() + const socket = FakeWebSocket.sockets[0]! + await vi.waitFor(() => { expect(socket.sent).toHaveLength(1) }) + const reason = new Error('caller left') + abort.abort(reason) + await expect(pending).rejects.toBe(reason) + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'cancel']) + expect(() => { handle.send('after abort') }).toThrow('client api: probe/attach stream has terminated') + expect(() => handle[Symbol.asyncIterator]().next()).toThrow('client api: probe/attach stream has one consumer') + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + }) }) describe('Remote stream client carrier lifecycle', () => { @@ -2737,3 +2916,9 @@ async function withFakeWebSocket( else Object.defineProperty(globalThis, 'location', locationDescriptor) } } + +async function drainStream(source: AsyncIterable): Promise { + const values: unknown[] = [] + for await (const value of source) values.push(value) + return values +} diff --git a/packages/api/gateway/tests/gateway.host.spec.ts b/packages/api/gateway/tests/gateway.host.spec.ts index d88de09fc3..8d58b1d553 100644 --- a/packages/api/gateway/tests/gateway.host.spec.ts +++ b/packages/api/gateway/tests/gateway.host.spec.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest' import { Context, Service, symbols } from '@deepseek-ai/cordis' import { z } from 'zod' import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' -import type { HostConnectionHandle } from '@deepseek-ai/dsh-client-connection' +import type { HostConnectionHandle, PeerId, PeerScope } from '@deepseek-ai/dsh-client-connection' import type { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver' import { bindTypertRemote, @@ -112,9 +112,12 @@ class FakeConnectionService extends Service { channel: string | undefined matches: ((endpoint: string) => boolean) | undefined handler: FakeRpcHandler | undefined + /** The operator Peer every call this fake dispatches speaks for. */ + readonly peers: { readonly operator: PeerScope } constructor(ctx: Context) { super(ctx, 'connection') + this.peers = { operator: { id: 'fake-operator' as PeerId, ctx, dispose: () => Promise.resolve() } } } get rpc() { diff --git a/packages/api/gateway/tests/stream-protocol.host.spec.ts b/packages/api/gateway/tests/stream-protocol.host.spec.ts index f353dce901..c5803e8dd3 100644 --- a/packages/api/gateway/tests/stream-protocol.host.spec.ts +++ b/packages/api/gateway/tests/stream-protocol.host.spec.ts @@ -14,6 +14,15 @@ describe('Remote stream wire protocol', () => { expect(parseRemoteStreamClientMessage(JSON.stringify({ type: 'cancel', streamId: 'stream-1', }))).toEqual({ type: 'cancel', streamId: 'stream-1' }) + expect(parseRemoteStreamClientMessage(JSON.stringify({ + type: 'item', streamId: 'stream-1', value: { line: 'ls\n' }, + }))).toEqual({ type: 'item', streamId: 'stream-1', value: { line: 'ls\n' } }) + expect(parseRemoteStreamClientMessage(JSON.stringify({ + type: 'item', streamId: 'stream-1', + }))).toEqual({ type: 'item', streamId: 'stream-1' }) + expect(parseRemoteStreamClientMessage(JSON.stringify({ + type: 'end', streamId: 'stream-1', + }))).toEqual({ type: 'end', streamId: 'stream-1' }) }) it.each([ @@ -21,12 +30,22 @@ describe('Remote stream wire protocol', () => { { type: 'open', streamId: 'stream-1', endpoint: '', payload: {} }, { type: 'open', streamId: 'stream-1', endpoint: 'feed/follow' }, { type: 'cancel', streamId: 'stream-1', extra: true }, + { type: 'item', streamId: '', value: 'line' }, + { type: 'item', streamId: 'stream-1', value: 'line', extra: true }, + { type: 'item', streamId: 'stream-1', endpoint: 'feed/follow' }, + { type: 'end', streamId: '' }, + { type: 'end', streamId: 'stream-1', value: 'line' }, { type: 'unknown', streamId: 'stream-1' }, ])('rejects an invalid client message: %j', (message) => { expect(() => parseRemoteStreamClientMessage(JSON.stringify(message))) .toThrow('api gateway: invalid Remote stream client message') }) + it('rejects an uplink item whose value is not lossless JSON', () => { + expect(() => parseRemoteStreamClientMessage('{"type":"item","streamId":"stream-1","value":-0}')) + .toThrow('api gateway: invalid Remote stream client message') + }) + it('accepts every server message variant', () => { expect(parseRemoteStreamServerMessage(JSON.stringify({ type: 'item', streamId: 'stream-1', value: null, diff --git a/packages/api/gateway/tests/stream-server.host.spec.ts b/packages/api/gateway/tests/stream-server.host.spec.ts index bd631619a2..3ab3c44b3b 100644 --- a/packages/api/gateway/tests/stream-server.host.spec.ts +++ b/packages/api/gateway/tests/stream-server.host.spec.ts @@ -1,5 +1,7 @@ import { once } from 'node:events' import { createServer, type Server } from 'node:http' +import { Context } from '@deepseek-ai/cordis' +import { remoteErrorOf, type PeerId, type PeerScope } from '@deepseek-ai/dsh-typert-protocol' import { afterEach, describe, expect, it, vi } from 'vitest' import WebSocket from 'ws' import { @@ -26,7 +28,7 @@ afterEach(async () => { describe('Remote stream mux server carrier lifecycle', () => { it('sends WebSocket Ping control frames without application messages', async () => { - const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal), 20) + const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => waitForAbort(control.signal), 20) const client = await connect(entry.url) const serverSocket = acceptedSocket(entry.mux) const messages = vi.fn() @@ -51,7 +53,7 @@ describe('Remote stream mux server carrier lifecycle', () => { }) it('requires two missed heartbeats before terminating an unresponsive socket', async () => { - const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal), 20) + const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => waitForAbort(control.signal), 20) const client = await connect(entry.url) const serverSocket = acceptedSocket(entry.mux) serverSocket.removeAllListeners('pong') @@ -66,7 +68,7 @@ describe('Remote stream mux server carrier lifecycle', () => { }) it('keeps the socket when a delayed Pong arrives before the final check', async () => { - const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal), 20) + const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => waitForAbort(control.signal), 20) const client = await connect(entry.url, false) const serverSocket = acceptedSocket(entry.mux) const terminated = vi.spyOn(serverSocket, 'terminate') @@ -92,7 +94,7 @@ describe('Remote stream mux server carrier lifecycle', () => { }) it('rejects binary, malformed, and duplicate logical-stream messages', async () => { - const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal)) + const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => waitForAbort(control.signal)) const binary = await connect(entry.url) const binaryClosed = once(binary, 'close') @@ -115,16 +117,16 @@ describe('Remote stream mux server carrier lifecycle', () => { expect(duplicateEvent[0]).toBe(1008) expect(String(duplicateEvent[1])).toBe('invalid Remote stream request') - const noInput = await connect(entry.url) - noInput.send(openFrame('no-input')) - noInput.send(JSON.stringify({ type: 'input', streamId: 'no-input', value: 'unexpected' })) - const noInputEvent = await once(noInput, 'close') - expect(noInputEvent[0]).toBe(1008) - expect(String(noInputEvent[1])).toBe('invalid Remote stream request') + const unknownType = await connect(entry.url) + unknownType.send(openFrame('unknown-type')) + unknownType.send(JSON.stringify({ type: 'input', streamId: 'unknown-type', value: 'unexpected' })) + const unknownTypeEvent = await once(unknownType, 'close') + expect(unknownTypeEvent[0]).toBe(1008) + expect(String(unknownTypeEvent[1])).toBe('invalid Remote stream request') }) it('accepts all ws text representations and terminates a carrier error', async () => { - const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal)) + const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => waitForAbort(control.signal)) const client = await connect(entry.url) const serverSocket = acceptedSocket(entry.mux) const cancel = JSON.stringify({ type: 'cancel', streamId: 'absent' }) @@ -142,16 +144,12 @@ describe('Remote stream mux server carrier lifecycle', () => { const didOpen = new Promise((resolve) => { opened = resolve }) let returned!: () => void const didReturn = new Promise((resolve) => { returned = resolve }) - const entry = await startMux(async (_endpoint, _payload, signal) => { + const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => { opened() - return cleanlyCancelled(signal, returned) + return cleanlyCancelled(control.signal, returned) }) const client = await connect(entry.url) - const frames: unknown[] = [] - client.on('message', (data) => { - if (!Buffer.isBuffer(data)) throw new TypeError('fixture expected a Buffer frame') - frames.push(JSON.parse(data.toString('utf8')) as unknown) - }) + const frames = collectFrames(client) client.send(openFrame('cancelled')) await didOpen client.send(JSON.stringify({ type: 'cancel', streamId: 'cancelled' })) @@ -171,13 +169,7 @@ describe('Remote stream mux server carrier lifecycle', () => { const client = await connect(entry.url) client.send(openFrame('write-failure')) await didOpen - const serverSocket = acceptedSocket(entry.mux) - const mutable = serverSocket as { - send(data: unknown, callback: (error?: Error) => void): void - } - mutable.send = (_data, callback): void => { - callback(new Error('fixture ws write failure')) - } + failWrites(acceptedSocket(entry.mux)) const closed = once(client, 'close') release() @@ -186,6 +178,43 @@ describe('Remote stream mux server carrier lifecycle', () => { expect(String(closeEvent[1])).toBe('Remote stream failure could not be delivered') }) + it('closes the carrier when the end frame cannot be written', async () => { + const entry = await startMux(async () => (async function *(): AsyncIterable {})()) + const client = await connect(entry.url) + failWrites(acceptedSocket(entry.mux)) + + const closed = once(client, 'close') + client.send(openFrame('empty')) + const closeEvent = await closed + expect(closeEvent[0]).toBe(1011) + expect(String(closeEvent[1])).toBe('Remote stream failure could not be delivered') + }) + + it('leaves a failed stream silent once its socket is no longer open', async () => { + const opened = Promise.withResolvers() + const entry = await startMux(async () => { + opened.resolve(undefined) + throw new Error('fixture opener failure') + }) + const client = await connect(entry.url) + const frames = collectFrames(client) + const serverSocket = acceptedSocket(entry.mux) + Object.defineProperty(serverSocket, 'readyState', { configurable: true, value: WebSocket.CLOSING }) + try { + client.send(openFrame('silent')) + await opened.promise + // The failure path awaits nothing before it inspects the socket, so one + // macrotask hop drains every microtask it can schedule. + await new Promise((resolve) => { setImmediate(resolve) }) + expect(frames).toEqual([]) + } finally { + Reflect.deleteProperty(serverSocket, 'readyState') + } + expect(serverSocket.readyState).toBe(WebSocket.OPEN) + client.close() + await once(client, 'close') + }) + it('contains an item produced after its socket closes', async () => { let release!: () => void const released = new Promise((resolve) => { release = resolve }) @@ -210,9 +239,9 @@ describe('Remote stream mux server carrier lifecycle', () => { const didOpen = new Promise((resolve) => { opened = resolve }) let returned!: () => void const didReturn = new Promise((resolve) => { returned = resolve }) - const entry = await startMux(async (_endpoint, _payload, signal) => { + const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => { opened() - return cleanlyCancelled(signal, returned) + return cleanlyCancelled(control.signal, returned) }) const client = await connect(entry.url) client.send(openFrame('active')) @@ -228,16 +257,234 @@ describe('Remote stream mux server carrier lifecycle', () => { }) }) -const mapFailure: RemoteStreamFailureMapper = error => ({ - code: 'internal', - message: error instanceof Error ? error.message : String(error), - details: {}, +describe('Remote stream mux server Peer binding', () => { + it('hands the admitted Peer to every opener and closes the socket when its scope is disposed', async () => { + const peer = await fixturePeer() + const seen: PeerScope[] = [] + const entry = await startMux(async (_endpoint, _payload, _uplink, opened, control) => { + seen.push(opened) + return waitForAbort(control.signal) + }, 2_000, 262_144, peer) + const client = await connect(entry.url) + client.send(openFrame('bound')) + await vi.waitFor(() => { expect(seen).toHaveLength(1) }) + expect(seen[0]).toBe(peer) + + const closed = once(client, 'close') + await peer.dispose() + const [code, reason] = await closed + expect(code).toBe(1001) + expect(String(reason)).toBe('peer left') + }) }) -async function startMux(open: RemoteStreamOpener, heartbeatIntervalMs = 2_000): Promise { - const mux = new RemoteStreamMuxServer(open, mapFailure, heartbeatIntervalMs) +describe('Remote stream mux server uplink', () => { + it('buffers uplink items sent before the opener resolves and half-closes on end', async () => { + const release = Promise.withResolvers() + const entry = await startMux(async (_endpoint, _payload, uplink) => { + await release.promise + return echoUplink(uplink) + }) + const client = await connect(entry.url) + const frames = collectFrames(client) + const received = countMessages(acceptedSocket(entry.mux)) + client.send(openFrame('echo')) + client.send(itemFrame('echo', 'one')) + client.send(itemFrame('echo')) + await vi.waitFor(() => { expect(received.count).toBe(3) }) + expect(frames).toEqual([]) + + release.resolve(undefined) + await vi.waitFor(() => { + expect(frames).toEqual([ + { type: 'item', streamId: 'echo', value: 'one' }, + { type: 'item', streamId: 'echo' }, + ]) + }) + client.send(endFrame('echo')) + await vi.waitFor(() => { expect(frames.at(-1)).toEqual({ type: 'end', streamId: 'echo' }) }) + expect(frames).toHaveLength(3) + client.close() + await once(client, 'close') + }) + + it('fails a stream on an item after end and keeps the socket open', async () => { + const entry = await startMux(async (endpoint, _payload, uplink, _peer, control) => + endpoint === 'fixture/echo' ? echoUplink(uplink) : waitForAbort(control.signal)) + const client = await connect(entry.url) + const frames = collectFrames(client) + client.send(openFrame('held')) + client.send(endFrame('held')) + client.send(endFrame('held')) + client.send(itemFrame('held', 'late')) + await vi.waitFor(() => { + expect(frames).toEqual([{ + type: 'error', + streamId: 'held', + error: { + code: 'gateway/protocol', + message: 'api gateway: Remote stream uplink item after end', + details: { endpoint: 'fixture/follow' }, + }, + }]) + }) + + expect(client.readyState).toBe(WebSocket.OPEN) + client.send(openFrame('again', 'fixture/echo')) + client.send(itemFrame('again', 'still served')) + await vi.waitFor(() => { + expect(frames.at(-1)).toEqual({ type: 'item', streamId: 'again', value: 'still served' }) + }) + client.close() + await once(client, 'close') + }) + + it('fails a stream whose buffered uplink exceeds the configured bytes and drops later items', async () => { + const release = Promise.withResolvers() + const entry = await startMux(async () => (async function *(): AsyncIterable { + await release.promise + })(), 2_000, 48) + const client = await connect(entry.url) + const frames = collectFrames(client) + const received = countMessages(acceptedSocket(entry.mux)) + client.send(openFrame('big')) + client.send(itemFrame('big', 'x'.repeat(64))) + client.send(itemFrame('big', 'dropped')) + client.send(endFrame('big')) + await vi.waitFor(() => { expect(received.count).toBe(4) }) + expect(frames).toEqual([]) + + release.resolve(undefined) + await vi.waitFor(() => { + expect(frames).toEqual([{ + type: 'error', + streamId: 'big', + error: { + code: 'gateway/uplink-overflow', + message: 'api gateway: Remote stream uplink exceeded 48 buffered bytes', + details: { endpoint: 'fixture/follow' }, + }, + }]) + }) + client.close() + await once(client, 'close') + }) + + it('closes the socket for uplink frames naming an unknown stream', async () => { + const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => waitForAbort(control.signal)) + for (const frame of [itemFrame('absent', 1), endFrame('absent')]) { + const client = await connect(entry.url) + const closed = once(client, 'close') + client.send(frame) + const closeEvent = await closed + expect(closeEvent[0]).toBe(1008) + expect(String(closeEvent[1])).toBe('invalid Remote stream request') + } + }) + + it('ends a pending uplink read when the socket closes', async () => { + const read = Promise.withResolvers() + const opened = Promise.withResolvers() + const entry = await startMux(async (_endpoint, _payload, uplink, _peer, control) => { + uplink[Symbol.asyncIterator]().next().then(read.resolve, read.resolve) + opened.resolve(undefined) + return waitForAbort(control.signal) + }) + const client = await connect(entry.url) + client.send(openFrame('pending')) + await opened.promise + client.close() + await once(client, 'close') + await expect(read.promise).resolves.toMatchObject({ message: 'Remote stream socket closed' }) + }) + + it('drops uplink items after the Host stops reading', async () => { + const release = Promise.withResolvers() + const returned = Promise.withResolvers() + const entry = await startMux(async (_endpoint, _payload, uplink) => { + const iterator = uplink[Symbol.asyncIterator]() + const first: IteratorResult = await iterator.next() + await iterator.return?.() + returned.resolve(undefined) + return (async function *(): AsyncIterable { + await release.promise + yield { first: first.value, afterReturn: (await iterator.next()).done } + })() + }) + const client = await connect(entry.url) + const frames = collectFrames(client) + const received = countMessages(acceptedSocket(entry.mux)) + client.send(openFrame('closed')) + client.send(itemFrame('closed', 'one')) + await returned.promise + client.send(itemFrame('closed', 'two')) + client.send(endFrame('closed')) + client.send(itemFrame('closed', 'three')) + await vi.waitFor(() => { expect(received.count).toBe(5) }) + + release.resolve(undefined) + await vi.waitFor(() => { + expect(frames).toEqual([ + { type: 'item', streamId: 'closed', value: { first: 'one', afterReturn: true } }, + { type: 'end', streamId: 'closed' }, + ]) + }) + client.close() + await once(client, 'close') + }) + + it('reports a second uplink consumer and a second pending read as stream failures', async () => { + const entry = await startMux(async (endpoint, _payload, uplink) => { + const iterator = uplink[Symbol.asyncIterator]() + if (endpoint === 'fixture/twice') uplink[Symbol.asyncIterator]() + void iterator.next().catch(() => undefined) + await iterator.next() + return echoUplink(uplink) + }) + const client = await connect(entry.url) + const frames = collectFrames(client) + client.send(openFrame('twice', 'fixture/twice')) + client.send(openFrame('pending', 'fixture/pending')) + await vi.waitFor(() => { + expect(frames).toEqual(expect.arrayContaining([ + { + type: 'error', + streamId: 'twice', + error: { code: 'internal', message: 'api gateway: Remote stream uplink inbox already has a consumer', details: {} }, + }, + { + type: 'error', + streamId: 'pending', + error: { code: 'internal', message: 'api gateway: Remote stream uplink inbox has one pending read', details: {} }, + }, + ])) + }) + expect(frames).toHaveLength(2) + client.close() + await once(client, 'close') + }) +}) + +const mapFailure: RemoteStreamFailureMapper = (error) => { + const remote = remoteErrorOf(error) + if (remote !== undefined) return { code: remote.code, message: remote.message, details: remote.details } + return { + code: 'internal', + message: error instanceof Error ? error.message : String(error), + details: {}, + } +} + +async function startMux( + open: RemoteStreamOpener, + heartbeatIntervalMs = 2_000, + streamInboxBytes = 262_144, + peer?: PeerScope, +): Promise { + const admitted = peer ?? await fixturePeer() + const mux = new RemoteStreamMuxServer(open, mapFailure, heartbeatIntervalMs, streamInboxBytes) const http = createServer() - http.on('upgrade', (request, socket, head) => { mux.handleUpgrade(request, socket, head) }) + http.on('upgrade', (request, socket, head) => { mux.handleUpgrade(request, socket, head, admitted) }) await new Promise((resolve, reject) => { http.once('error', reject) http.listen(0, '127.0.0.1', () => { @@ -252,6 +499,18 @@ async function startMux(open: RemoteStreamOpener, heartbeatIntervalMs = 2_000): return entry } +/** A Peer whose scope is a plain Cordis fiber, so `peer.ctx.effect` and `dispose()` behave as the registry's do. */ +async function fixturePeer(): Promise { + const root = new Context() + const fiber = root.plugin(() => {}) + await fiber + return { + id: 'fixture-peer' as PeerId, + ctx: fiber.ctx, + dispose: async () => { await fiber.dispose() }, + } +} + async function connect(url: string, autoPong = true): Promise { const socket = new WebSocket(url, { autoPong }) await once(socket, 'open') @@ -265,8 +524,45 @@ function acceptedSocket(mux: RemoteStreamMuxServer): WebSocket { return socket } -function openFrame(streamId: string): string { - return JSON.stringify({ type: 'open', streamId, endpoint: 'fixture/follow', payload: {} }) +function collectFrames(client: WebSocket): Record[] { + const frames: Record[] = [] + client.on('message', (data) => { + if (!Buffer.isBuffer(data)) throw new TypeError('fixture expected a Buffer frame') + frames.push(JSON.parse(data.toString('utf8')) as Record) + }) + return frames +} + +/** Count the text frames the mux has already dispatched, so a test can wait for the Host to hold them. */ +function countMessages(serverSocket: WebSocket): { count: number } { + const received = { count: 0 } + serverSocket.on('message', () => { received.count += 1 }) + return received +} + +function failWrites(serverSocket: WebSocket): void { + const mutable = serverSocket as { + send(data: unknown, callback: (error?: Error) => void): void + } + mutable.send = (_data, callback): void => { + callback(new Error('fixture ws write failure')) + } +} + +function openFrame(streamId: string, endpoint = 'fixture/follow'): string { + return JSON.stringify({ type: 'open', streamId, endpoint, payload: {} }) +} + +function itemFrame(streamId: string, value?: unknown): string { + return JSON.stringify(value === undefined ? { type: 'item', streamId } : { type: 'item', streamId, value }) +} + +function endFrame(streamId: string): string { + return JSON.stringify({ type: 'end', streamId }) +} + +async function *echoUplink(uplink: AsyncIterable): AsyncIterable { + for await (const value of uplink) yield value } async function *waitForAbort(signal: AbortSignal): AsyncIterable { diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index 52da57850d..fc53358241 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -7,7 +7,7 @@ import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { LlmAttemptId } from '@deepseek-ai/dsh-llm' import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client' import { SESSION_FORMAT_VERSION, SessionSeq } from '@deepseek-ai/dsh-session/types' -import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock' +import { ok, streamHandle, type RemoteMock } from '@deepseek-ai/dsh-remote-mock' import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' import { ClientSessions, SessionCreateError, SessionForkError } from '../src/client/sessions/service.ts' import { scopeOf } from '../src/client/scope.ts' @@ -551,7 +551,7 @@ describe('Agent scope disposal lifecycle', () => { if (signal === undefined) throw new Error('fixture requires a signal') followSignal = signal let opened = false - return { + return streamHandle({ [Symbol.asyncIterator]: () => ({ next: () => { if (!opened) { @@ -588,7 +588,7 @@ describe('Agent scope disposal lifecycle', () => { }) }, }), - } + }) }) const readiness = b.ctx.plugin(() => undefined) await readiness @@ -625,7 +625,7 @@ describe('Agent scope disposal lifecycle', () => { const closeGate = Promise.withResolvers() closeGates.set(sessionId, closeGate) let opened = false - return { + return streamHandle({ [Symbol.asyncIterator]: () => ({ next: () => { if (!opened) { @@ -655,7 +655,7 @@ describe('Agent scope disposal lifecycle', () => { }) }, }), - } + }) }) const readiness = b.ctx.plugin(() => undefined) await readiness diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts index ece1a9ff99..fb90a0dec1 100644 --- a/packages/api/session-controller/tests/transport.client.spec.ts +++ b/packages/api/session-controller/tests/transport.client.spec.ts @@ -5,6 +5,8 @@ import { type RemoteStreamOptions, } from '@deepseek-ai/dsh-api-gateway/client' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol/client' +import { streamHandle } from '@deepseek-ai/dsh-remote-mock' import { LlmAttemptId } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session/types' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' @@ -106,7 +108,11 @@ class ScriptedSessionRemote implements SessionTransportRemote { private readonly holdControl = true, ) {} - async *follow(request: SessionFollowRequest, signal = new AbortController().signal): AsyncIterable { + follow(request: SessionFollowRequest, signal = new AbortController().signal): RemoteStreamHandle { + return streamHandle(this.followFrames(request, signal)) + } + + private async *followFrames(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable { const generation = this.generations.shift() if (generation === undefined) throw new Error('no scripted Session generation') this.followRequests.push(request) @@ -128,7 +134,11 @@ class ScriptedSessionRemote implements SessionTransportRemote { return Promise.resolve(result) } - async *control(signal = new AbortController().signal): AsyncIterable { + control(signal = new AbortController().signal): RemoteStreamHandle { + return streamHandle(this.controlSequence(signal)) + } + + private async *controlSequence(signal: AbortSignal): AsyncIterable { for (const frame of this.controlFrames) yield frame if (this.holdControl && !signal.aborted) { await new Promise((resolve) => { diff --git a/packages/api/terminal-controller/package.json b/packages/api/terminal-controller/package.json index 35e9e26ae2..8862def77e 100644 --- a/packages/api/terminal-controller/package.json +++ b/packages/api/terminal-controller/package.json @@ -73,6 +73,7 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/api/terminal-controller/tests/model.client.spec.ts b/packages/api/terminal-controller/tests/model.client.spec.ts index eb2b64a92a..2c34b9c54b 100644 --- a/packages/api/terminal-controller/tests/model.client.spec.ts +++ b/packages/api/terminal-controller/tests/model.client.spec.ts @@ -4,6 +4,7 @@ import { afterEach, expect, it, vi } from 'vitest' import { RemoteStream, RemoteStreamCarrierError, type ClientRemote, type RemoteStreamOptions } from '@deepseek-ai/dsh-api-gateway/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { streamMethod } from '@deepseek-ai/dsh-remote-mock' import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { TerminalView, type TerminalRemote } from '../src/client/model.ts' @@ -31,22 +32,22 @@ function failure(message: string): RemoteResult { function fixture(prepareStream?: (stream: RemoteStream) => void) { const remote: TerminalRemote = { - retain: vi.fn(async function* (_session, _id, signal) { + retain: vi.fn(streamMethod(async function* (_session, _id, signal) { yield { type: 'retained' } await new Promise((resolve) => { if (signal?.aborted) resolve() else signal?.addEventListener('abort', () => { resolve() }, { once: true }) }) - }), + })), shells: vi.fn(async () => success([info.shell])), environment: vi.fn(async () => success(environment)), list: vi.fn(async () => success([])), create: vi.fn(async (_sessionId, request) => success({ ...info, id: request.id })), close: vi.fn(async () => success(undefined)), rename: vi.fn(async () => success(undefined)), write: vi.fn(async () => success(undefined)), resize: vi.fn(async () => success(undefined)), - follow: vi.fn(async function* (_sessionId, id, attachmentId, signal) { + follow: vi.fn(streamMethod(async function* (_sessionId, id, attachmentId, signal) { yield { type: 'snapshot', sequence: 0, screen: 'ready', info: { ...info, id, controllerId: attachmentId } } await untilAborted(signal) - }), + })), } const options: RemoteStreamOptions[] = [] const streams: { dispose(): Promise; restart(): void }[] = [] @@ -103,7 +104,7 @@ it('does not let a late input failure downgrade a newer connection', async () => it('waits for render acknowledgement before publishing subsequent output', async () => { const { model, remote } = fixture() - vi.mocked(remote.follow).mockImplementation(async function* (_sessionId, id, controllerId, signal) { + vi.mocked(remote.follow).mockImplementation(streamMethod(async function* (_sessionId, id, controllerId, signal) { const frames: TerminalFrame[] = [ { type: 'snapshot', sequence: 0, screen: '', info: { ...info, id, controllerId } }, { type: 'output', sequence: 1, data: 'first' }, @@ -111,7 +112,7 @@ it('waits for render acknowledgement before publishing subsequent output', async ] for (const frame of frames) yield frame await untilAborted(signal) - }) + })) await mount(model) await expect.poll(() => model.state.getSnapshot().render?.frame.type).toBe('snapshot') acknowledge(model) @@ -209,7 +210,7 @@ it.each([ ['repeated snapshot', [{ type: 'snapshot', sequence: 4, screen: '', info }, { type: 'snapshot', sequence: 4, screen: '', info }], 'Unexpected terminal screen snapshot'], ] satisfies readonly (readonly [string, readonly TerminalFrame[], string])[])('refuses a malformed output stream: %s', async (_name, frames, message) => { const { model, remote } = fixture() - vi.mocked(remote.follow).mockImplementation(async function* () { yield* frames }) + vi.mocked(remote.follow).mockImplementation(streamMethod(async function* () { yield* frames })) await mount(model) if (frames[0]?.type === 'snapshot') { await expect.poll(() => model.state.getSnapshot().render).toBeDefined() @@ -221,10 +222,10 @@ it.each([ it('retains an exited screen and closes controls when its stream ends', async () => { const { model, remote } = fixture() - vi.mocked(remote.follow).mockImplementation(async function* (_sessionId, id, controllerId) { + vi.mocked(remote.follow).mockImplementation(streamMethod(async function* (_sessionId, id, controllerId) { yield { type: 'snapshot', sequence: 0, screen: 'last screen', info: { ...info, id, controllerId } } yield { type: 'state', info: { ...info, id, controllerId, state: 'exited', exitCode: 3 } } - }) + })) await connected(model) await expect.poll(() => model.state.getSnapshot().phase).toBe('closed') expect(model.state.getSnapshot()).toMatchObject({ writable: false, info: { state: 'exited', exitCode: 3 }, render: { frame: { screen: 'last screen' } } }) @@ -232,9 +233,9 @@ it('retains an exited screen and closes controls when its stream ends', async () it('reports an attachment ending while the shell is still running and permits manual reconnect', async () => { const { model, remote } = fixture() - vi.mocked(remote.follow).mockImplementationOnce(async function* (_sessionId, id, controllerId) { + vi.mocked(remote.follow).mockImplementationOnce(streamMethod(async function* (_sessionId, id, controllerId) { yield { type: 'snapshot', sequence: 0, screen: '', info: { ...info, id, controllerId } } - }) + })) await connected(model) await expect.poll(() => model.state.getSnapshot().issue).toBe('attachmentEnded') model.connect() @@ -243,11 +244,11 @@ it('reports an attachment ending while the shell is still running and permits ma it('publishes remote control transfer without replacing the retained screen', async () => { const { model, remote } = fixture() - vi.mocked(remote.follow).mockImplementation(async function* (_sessionId, id, controllerId, signal) { + vi.mocked(remote.follow).mockImplementation(streamMethod(async function* (_sessionId, id, controllerId, signal) { yield { type: 'snapshot', sequence: 0, screen: 'retained', info: { ...info, id, controllerId } } yield { type: 'state', info: { ...info, id } } await untilAborted(signal) - }) + })) await connected(model) await expect.poll(() => model.state.getSnapshot().writable).toBe(false) expect(model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'retained' }) @@ -298,10 +299,10 @@ it('ignores an already delivered screen when its attachment is replaced before t it('ignores an already settled stream error after a newer attachment has become writable', async () => { const barrier = deliveryBarrier() const { model, remote } = fixture(barrier.prepare) - vi.mocked(remote.follow).mockImplementationOnce(async function* () { + vi.mocked(remote.follow).mockImplementationOnce(streamMethod(async function* () { yield* [] throw new Error('retired attachment failed') - }) + })) await mount(model) await barrier.ready model.connect() @@ -338,12 +339,12 @@ it.each(['write', 'resize'] as const)('keeps a fresh attachment writable when a const { model, remote, generation } = fixture() generation.set({ id: 1, host: { home: '/home/fixture' } }) const disconnected = Promise.withResolvers() - vi.mocked(remote.follow).mockImplementationOnce(async function* (_sessionId, id, controllerId, signal) { + vi.mocked(remote.follow).mockImplementationOnce(streamMethod(async function* (_sessionId, id, controllerId, signal) { yield { type: 'snapshot', sequence: 0, screen: 'before disconnect', info: { ...info, id, controllerId } } await Promise.race([disconnected.promise, untilAborted(signal)]) if (signal?.aborted) return throw new RemoteStreamCarrierError('connection lost') - }) + })) await connected(model) const pending = Promise.withResolvers>() if (operation === 'write') { @@ -516,12 +517,12 @@ it.each(['write', 'resize'] as const)('keeps the output connection when %s loses const transfer = Promise.withResolvers() const response = Promise.withResolvers>() cleanups.push(() => { transfer.resolve(undefined); response.resolve(success(undefined)) }) - vi.mocked(remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) { + vi.mocked(remote.follow).mockImplementation(streamMethod(async function* (_session, id, controllerId, signal) { yield { type: 'snapshot', sequence: 0, screen: 'retained screen', info: { ...info, id, controllerId } } await transfer.promise yield { type: 'state', info: { ...info, id } } await untilAborted(signal) - }) + })) await connected(model) vi.mocked(remote[operation]).mockReturnValueOnce(response.promise) if (operation === 'write') model.write('before transfer') @@ -542,12 +543,12 @@ it('keeps an exited screen when a pending input is refused after the exit state const exit = Promise.withResolvers() const response = Promise.withResolvers>() cleanups.push(() => { exit.resolve(undefined); response.resolve(success(undefined)) }) - vi.mocked(remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) { + vi.mocked(remote.follow).mockImplementation(streamMethod(async function* (_session, id, controllerId, signal) { yield { type: 'snapshot', sequence: 0, screen: 'final screen', info: { ...info, id, controllerId } } await exit.promise yield { type: 'state', info: { ...info, id, state: 'exited', exitCode: 0 } } await untilAborted(signal) - }) + })) await connected(model) vi.mocked(remote.write).mockReturnValueOnce(response.promise) model.write('exit race') diff --git a/packages/api/terminal-controller/tests/recovery.client.spec.ts b/packages/api/terminal-controller/tests/recovery.client.spec.ts index b8ed76b366..690dd9bf0d 100644 --- a/packages/api/terminal-controller/tests/recovery.client.spec.ts +++ b/packages/api/terminal-controller/tests/recovery.client.spec.ts @@ -4,6 +4,7 @@ import { afterEach, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { streamMethod } from '@deepseek-ai/dsh-remote-mock' import { RemoteStream, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -29,25 +30,25 @@ function storage() { function fixture() { const remote: TerminalRemote = { - retain: vi.fn(async function* (_session, _id, signal) { + retain: vi.fn(streamMethod(async function* (_session, _id, signal) { yield { type: 'retained' } await new Promise((resolve) => { if (signal?.aborted) resolve() else signal?.addEventListener('abort', () => { resolve() }, { once: true }) }) - }), + })), shells: vi.fn(async () => success([info.shell])), environment: vi.fn(async () => success(environment)), list: vi.fn(async () => success([])), create: vi.fn(async (_session, request) => success({ ...info, id: request.id })), close: vi.fn(async () => success(undefined)), rename: vi.fn(async () => success(undefined)), write: vi.fn(async () => success(undefined)), resize: vi.fn(async () => success(undefined)), - follow: vi.fn(async function* (_session, id, controllerId, signal) { + follow: vi.fn(streamMethod(async function* (_session, id, controllerId, signal) { yield { type: 'snapshot', sequence: 0, screen: 'retained', info: { ...info, id, controllerId } } await new Promise((resolve) => { if (signal?.aborted) resolve() else signal?.addEventListener('abort', () => { resolve() }, { once: true }) }) - }), + })), } const gateway: Pick = { $stream: options => new RemoteStream({ generation: createSnapshotStore(undefined) }, options) } function view() { @@ -489,7 +490,7 @@ it('waits for both active and detached stream finalizers during plugin disposal const finished = [Promise.withResolvers(), Promise.withResolvers()] cleanups.push(() => { for (const barrier of release) barrier.resolve(undefined) }) let index = 0 - vi.mocked(h.remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) { + vi.mocked(h.remote.follow).mockImplementation(streamMethod(async function* (_session, id, controllerId, signal) { const current = index++ try { yield { type: 'snapshot', sequence: 0, screen: 'screen', info: { ...info, id, controllerId } } @@ -502,7 +503,7 @@ it('waits for both active and detached stream finalizers during plugin disposal await release[current]!.promise finished[current]!.resolve(undefined) } - }) + })) const model = service.view(sessionId, 'tab', 'tab') model.mount() await model.refresh() @@ -625,14 +626,14 @@ it('waits for the window hold acknowledgement before restoring an output attachm const h = fixture() vi.mocked(h.remote.list).mockResolvedValue(success([info])) const acknowledge = Promise.withResolvers() - vi.mocked(h.remote.retain).mockImplementation(async function* (_session, _id, signal) { + vi.mocked(h.remote.retain).mockImplementation(streamMethod(async function* (_session, _id, signal) { await acknowledge.promise yield { type: 'retained' } await new Promise((resolve) => { if (signal?.aborted) resolve() else signal?.addEventListener('abort', () => { resolve() }, { once: true }) }) - }) + })) const { service } = await h.service() service.retainTabs([{ sessionId, tabId: 'restored', contentId: 'restored' }]) const model = service.view(sessionId, 'restored', 'restored') diff --git a/packages/api/terminal-controller/tests/retention.client.spec.ts b/packages/api/terminal-controller/tests/retention.client.spec.ts index a6c0f88ed1..11a87f3fe1 100644 --- a/packages/api/terminal-controller/tests/retention.client.spec.ts +++ b/packages/api/terminal-controller/tests/retention.client.spec.ts @@ -2,6 +2,7 @@ import { afterEach, expect, it, vi } from 'vitest' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { RemoteStream, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' +import { streamMethod } from '@deepseek-ai/dsh-remote-mock' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { TerminalRemote } from '../src/client/model.ts' import { TerminalWindowHold } from '../src/client/retention.ts' @@ -9,9 +10,14 @@ import type { TerminalRetentionFrame, WebTerminalId } from '../src/types.ts' const holds: TerminalWindowHold[] = [] afterEach(async () => { await Promise.all(holds.splice(0).map(hold => hold.dispose())) }) -function hold(retain: TerminalRemote['retain']) { +function hold(retain: Parameters>[0]) { const gateway: Pick = { $stream: options => new RemoteStream({ generation: createSnapshotStore(undefined) }, options) } - const held = new TerminalWindowHold(gateway, { retain } as TerminalRemote, 'session' as SessionId, 'terminal' as WebTerminalId) + const held = new TerminalWindowHold( + gateway, + { retain: streamMethod(retain) } as TerminalRemote, + 'session' as SessionId, + 'terminal' as WebTerminalId, + ) holds.push(held) return held } diff --git a/packages/api/workspace-controller/tests/model.client.spec.ts b/packages/api/workspace-controller/tests/model.client.spec.ts index b126805183..ad4ae864aa 100644 --- a/packages/api/workspace-controller/tests/model.client.spec.ts +++ b/packages/api/workspace-controller/tests/model.client.spec.ts @@ -23,6 +23,8 @@ import type { WorkspaceView, } from '../src/types.ts' import { RemoteError, type RemoteFailure, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol/client' +import { streamHandle } from '@deepseek-ai/dsh-remote-mock' import type { SessionId } from '@deepseek-ai/dsh-session/types' const sid = (id: string): SessionId => id as SessionId @@ -146,7 +148,9 @@ class FakeWorkspaceRemote implements WorkspaceRemote { return this.onUnpinSession(request) } - async *follow(_signal?: AbortSignal): AsyncGenerator {} + follow(_signal?: AbortSignal): RemoteStreamHandle { + return streamHandle((async function* () {})()) + } private record(method: string, request: unknown): void { this.calls.push({ method, request }) diff --git a/packages/api/workspace-files/package.json b/packages/api/workspace-files/package.json index a8b036b554..73df728df4 100644 --- a/packages/api/workspace-files/package.json +++ b/packages/api/workspace-files/package.json @@ -66,6 +66,7 @@ "@deepseek-ai/dsh-client-resources": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/packages/api/workspace-files/tests/fake-remote.client.ts b/packages/api/workspace-files/tests/fake-remote.client.ts index c310dc433f..f673145b90 100644 --- a/packages/api/workspace-files/tests/fake-remote.client.ts +++ b/packages/api/workspace-files/tests/fake-remote.client.ts @@ -5,6 +5,8 @@ */ import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol/client' +import { streamHandle } from '@deepseek-ai/dsh-remote-mock' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceFileWatchFrame, WorkspaceFileStat } from '../src/types.ts' import type { SupervisedStream, SupervisedStreamOptions, WorkspaceFilesRemote } from '../src/client/remote.ts' @@ -225,7 +227,7 @@ export class FakeRemote implements WorkspaceFilesRemote { this.statWaiters.delete(index) if (this.closed) resolve(this.closedStat()) }), - changes: (sessionId: SessionId, path: string, signal?: AbortSignal): AsyncIterable => { + changes: (sessionId: SessionId, path: string, signal?: AbortSignal): RemoteStreamHandle => { this.calls.push('changes') if (signal === undefined) throw new Error('the feed must hand its signal to the Host stream') const source = new Source(signal) @@ -235,7 +237,7 @@ export class FakeRemote implements WorkspaceFilesRemote { for (const waiter of this.watchWaiters.get(index) ?? []) waiter(watch) this.watchWaiters.delete(index) if (this.autoReady) source.push({ kind: 'ready' }) - return source + return streamHandle(source) }, } } diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 3077a60252..16135d1ee3 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/connection/README.md -README.md: 17bcb9940286b91433273cef2fcda3147249133b -README.zh.md: 6a090709414007ddb7fe7ca3fdd8c29b737a39dc +README.md: b15d8eff30d48aa5978c89bb43c8e24d8a51cc70 +README.zh.md: dd01a84d0721d8114d66964996f548bf474e22b9 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 17bcb99402..b15d8eff30 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -27,7 +27,7 @@ The package carries browser-to-Host Remote calls, exact Fetch responses, and con A static desktop page can provide `__DSH_TRANSPORT__.streamBaseUrl` for the HTTP origin of its owned Host. The Gateway uses that origin for its WebSocket while HTTP transport remains independently selected. The desktop carrier owns authentication; setting the origin alone grants no access. -The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; shell-owned compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The browser plugin reads the page transport, recovery settings, and location, then delegates to `installConnection(ctx, options)`. A composition that owns its carrier may call the same installer directly; the whole-client test tier does so. Each invocation creates one Context-owned service, so several Client trees can use different carriers in one realm. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md). +The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; shell-owned compositions provide equivalent Remote streams, including a duplex method's uplink, through `connection.rpc.open` without opening a WebSocket. The browser plugin reads the page transport, recovery settings, and location, then delegates to `installConnection(ctx, options)`. A composition that owns its carrier may call the same installer directly; the whole-client test tier does so. Each invocation creates one Context-owned service, so several Client trees can use different carriers in one realm. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md). ----- diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 6a09070941..dd01a84d07 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" 静态桌面页面可以通过 `__DSH_TRANSPORT__.streamBaseUrl` 提供其所拥有 Host 的 HTTP origin。Gateway 将该 origin 用于 WebSocket,HTTP 传输仍独立选择。桌面载体负责认证;仅设置 origin 不会授予访问权限。 -浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。浏览器插件读取页面 transport、恢复设置与 location,再委托 `installConnection(ctx, options)`。持有自身载体的组合可以直接调用同一个安装函数;整机客户端测试档就是这一消费者。每次调用都会创建一个归所属 Context 的服务,因此同一 realm 中的多棵 Client 树可以使用不同载体。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD`/`POST` 路由注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。 +浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流(包括双工方法的上行),不打开 WebSocket。浏览器插件读取页面 transport、恢复设置与 location,再委托 `installConnection(ctx, options)`。持有自身载体的组合可以直接调用同一个安装函数;整机客户端测试档就是这一消费者。每次调用都会创建一个归所属 Context 的服务,因此同一 realm 中的多棵 Client 树可以使用不同载体。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD`/`POST` 路由注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。 ----- diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index ea8af1e498..3a684757eb 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -35,6 +35,8 @@ "license": "MIT", "dependencies": { "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", "zod": "^4.4.3" }, diff --git a/packages/client/connection/src/client/rpc.ts b/packages/client/connection/src/client/rpc.ts index fef3b234bb..bf4427b41c 100644 --- a/packages/client/connection/src/client/rpc.ts +++ b/packages/client/connection/src/client/rpc.ts @@ -17,11 +17,12 @@ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ */ export type RpcFetch = (input: string | URL, init: RequestInit) => Promise -/** Worker-local opener for decoded Gateway Remote streams. */ +/** Worker-local opener for decoded Gateway Remote streams; `uplink` carries the Client's items for the stream. */ export type RpcStreamOpen = ( endpoint: string, payload: unknown, signal: AbortSignal, + uplink?: AsyncIterable, ) => AsyncIterable /** @@ -64,12 +65,12 @@ export function createWebConnectionRpc(doFetch?: RpcFetch, openStream?: RpcStrea return full.result }, ...openStream === undefined ? {} : { - open(channel, endpoint, payload, signal) { + open(channel, endpoint, payload, signal, uplink) { assertTarget(channel, endpoint) if (channel !== '/api') { throw new Error(`connection: worker-local streams require the /api channel, got ${JSON.stringify(channel)}`) } - return openStream(endpoint, payload, signal) + return openStream(endpoint, payload, signal, uplink) }, }, } diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts index ff6042d117..cdb14c1685 100644 --- a/packages/client/connection/src/http-bridge.ts +++ b/packages/client/connection/src/http-bridge.ts @@ -20,12 +20,15 @@ export const DEFAULT_MAX_REQUEST_BODY_BYTES = 300 * 1024 * 1024 * @param res - node:http response the bridge writes and owns to completion. * @param apiHandler - fetch-shaped API carrier the request is dispatched to. * @param maxRequestBodyBytes - maximum bytes buffered for a buffered route. + * @param bind - runs on the built Fetch `Request` before dispatch, so the + * caller can bind it to the Peer the node request was admitted as. */ export async function bridge( req: IncomingMessage, res: ServerResponse, apiHandler: ConnectionFetchHandler, maxRequestBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES, + bind?: (request: Request) => void, ): Promise { const abort = new AbortController() // Client-disconnect detection MUST hang off the response, not the request: @@ -80,6 +83,7 @@ export async function bridge( duplex: 'half', } as RequestInit & { duplex: 'half' }) } + bind?.(request) const response = await apiHandler.fetch(request) const requestUnread = bodyMode === 'streaming' && !req.readableEnded const responseHeaders = Object.fromEntries(response.headers.entries()) diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 7a4457e375..19848ac0e8 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -14,6 +14,8 @@ import { HostConnectionService } from './rpc-host.ts' import { ConnectionRecoveryConfigSchema, resolveConnectionConfig, type ConnectionRecoveryConfig } from './recovery-config.ts' export type { + PeerAdmission, + PeerRegistryHandle, ConnectionFetchMethod, ConnectionFetchHandler, ConnectionFetchRoute, @@ -33,7 +35,9 @@ export type { RpcMessage, ServerResponse, } from './rpc.ts' +export type { PeerId, PeerScope, RemoteInvocation } from '@deepseek-ai/dsh-typert-protocol' export { RpcId, transportError } from './rpc.ts' +export { PeerRegistry } from './peer-scope.ts' export { clientRequestSchema, rpcErrorSchema, @@ -141,13 +145,19 @@ export async function apply(ctx: Context, config?: ConnectionConfig): Promise { - const rejection = connection.requestRejection(req) - if (rejection !== undefined) { - res.writeHead(rejection) - res.end(rejection === 401 ? 'unauthorized' : 'forbidden') + const admission = connection.admit(req) + if ('rejection' in admission) { + res.writeHead(admission.rejection) + res.end(admission.rejection === 401 ? 'unauthorized' : 'forbidden') return } - await webCtx.waterfall('connection/request', req, res, () => bridge(req, res, fetchHandler, maxRequestBodyBytes)) + await webCtx.waterfall('connection/request', req, res, () => bridge( + req, + res, + fetchHandler, + maxRequestBodyBytes, + (request) => { connection.peers.bind(request, admission.peer) }, + )) }, } webCtx.effect(() => webCtx.webServer.register(route), 'client-connection: /api route') diff --git a/packages/client/connection/src/peer-scope.ts b/packages/client/connection/src/peer-scope.ts new file mode 100644 index 0000000000..d2c7490c34 --- /dev/null +++ b/packages/client/connection/src/peer-scope.ts @@ -0,0 +1,90 @@ +/** + * Peer registry: one `PeerScope` per party admitted to this Host. + * + * The framework knows a Peer only by its `PeerId` and the scope it owns. Who + * the Peer is and what it may do belong to whoever admitted it; that party + * attaches its state through `peer.ctx` or a registry keyed by the scope. + * @module @deepseek-ai/dsh-client-connection/src/peer-scope + */ + +import { randomUUID } from 'node:crypto' +import type { Context } from '@deepseek-ai/cordis' +import { createScope, type Scope } from '@deepseek-ai/dsh-scope' +import type { PeerId, PeerScope } from '@deepseek-ai/dsh-typert-protocol' +import type { PeerRegistryHandle } from './rpc.ts' + +/** + * One admitted Peer. The instance is its own scope key, so `scopeOf(peer.ctx)` + * returns it and events dispatched with `scopeTarget(subject, peer)` reach + * listeners registered through `peer.ctx` and nobody else. + */ +class AdmittedPeer implements PeerScope { + readonly id: PeerId = randomUUID() as PeerId + readonly ctx: Context + private readonly scope: Scope + private disposing: Promise | undefined + + /** + * @param owner - Connection plugin context the scope fiber hangs under. + * @param onClosed - registry callback run once disposal completes. + */ + constructor(owner: Context, private readonly onClosed: (peer: AdmittedPeer) => void) { + this.scope = createScope(owner, this) + this.ctx = this.scope.ctx + } + + /** Tear down every connection-lifetime registration; racing calls share one completion. */ + dispose(): Promise { + this.disposing ??= this.scope.dispose().then(() => { this.onClosed(this) }) + return this.disposing + } +} + +/** Host-wide registry of Peers and the carriers that speak for them. */ +export class PeerRegistry implements PeerRegistryHandle { + readonly operator: PeerScope + private readonly peers = new Set() + private readonly carriers = new WeakMap() + + /** @param ctx - Connection plugin context; every scope fiber is its child. */ + constructor(private readonly ctx: Context) { + this.operator = this.open() + } + + /** + * Admit one Peer on behalf of an admitter. + * @returns the opened scope; the admitter disposes it when the Peer leaves. + */ + open(): PeerScope { + const peer = new AdmittedPeer(this.ctx, (closed) => { this.peers.delete(closed) }) + this.peers.add(peer) + return peer + } + + /** + * Record which Peer one carrier object speaks for. + * @param carrier - node request, upgrade request, WebSocket, or Fetch `Request`. + * @param peer - the Peer it was admitted as. + */ + bind(carrier: object, peer: PeerScope): void { + this.carriers.set(carrier, peer) + } + + /** + * Read the Peer a carrier object was bound to. + * @param carrier - node request, upgrade request, WebSocket, or Fetch `Request`. + * @returns the bound Peer, or undefined when nobody bound one. + */ + of(carrier: object): PeerScope | undefined { + return this.carriers.get(carrier) + } + + /** + * Dispose every open Peer, the operator included. Each scope's registrations + * are torn down; sockets bound to a Peer close with it. + * @returns settles once every scope has quiesced. + */ + async dispose(): Promise { + await Promise.all([...this.peers].map(peer => peer.dispose())) + } +} diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index d74734e132..8cb658cd46 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -2,6 +2,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' +import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol' import { RpcId, type ClientRequest, @@ -12,7 +13,9 @@ import { bridge } from './http-bridge.ts' import { isTrustedApiRequest } from './api-request-trust.ts' import { API_PATH } from './api-path.ts' import type { BrowserAuth } from './browser-auth.ts' +import { PeerRegistry } from './peer-scope.ts' import type { + PeerAdmission, ConnectionIndexRequest, ConnectionIndexResponse, ConnectionFetchRoute, @@ -32,6 +35,9 @@ const INVALID_REQUEST_RPC_ID = RpcId('invalid-request') const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ +/** Resolve the Peer one Fetch request speaks for. */ +type PeerResolver = (request: Request) => PeerScope + interface ConnectionRpcInterceptor { readonly matches: ConnectionRpcEndpointMatcher readonly fetchHandler: ConnectionFetchHandler @@ -58,6 +64,8 @@ declare module '@deepseek-ai/cordis' { /** Host Connection service whose channel registrations belong to the caller fiber. */ export class HostConnectionService extends Service implements HostConnectionHandle { + /** Peers this Host answers to. */ + readonly peers: PeerRegistry private readonly interceptors = new Map() private readonly fetchRoutes = new Map() @@ -73,6 +81,8 @@ export class HostConnectionService extends Service implements HostConnectionHand private readonly browserAuth: BrowserAuth, ) { super(ctx, 'connection') + this.peers = new PeerRegistry(ctx) + ctx.effect(() => () => this.peers.dispose(), 'client-connection: Peer registry') } /** Generic channel registry scoped to the Context reading this service. */ @@ -99,6 +109,24 @@ export class HostConnectionService extends Service implements HostConnectionHand return this.browserAuth.isAuthenticated(request) ? undefined : 401 } + /** A bound carrier answers from its binding; everything else passes the fence and speaks for the operator. */ + admit(request: ConnectionTrustRequest): PeerAdmission { + const bound = this.peers.of(request) + if (bound !== undefined) return { peer: bound } + const rejection = this.requestRejection(request) + return rejection === undefined ? { peer: this.peers.operator } : { rejection } + } + + /** + * Resolve the Peer one Fetch request speaks for. A request nobody bound came + * from an in-process carrier the operator owns outright. + * @param request - dispatched Fetch request. + * @returns the bound Peer, or the operator. + */ + private peerOf(request: Request): PeerScope { + return this.peers.of(request) ?? this.peers.operator + } + /** Authenticate an index request through the process-token exchange or cookie. */ authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean { return this.browserAuth.authorizeIndex(request, response) @@ -161,18 +189,18 @@ export class HostConnectionService extends Service implements HostConnectionHand handler: ConnectionRpcHandler, ): () => Promise { assertChannel(channel) - const fetchHandler = rpcFetchHandler(channel, handler) + const fetchHandler = rpcFetchHandler(channel, handler, request => this.peerOf(request)) const route: WebRoute = { kind: 'prefix', path: channel, handler: async (req, res) => { - const rejection = this.requestRejection(req) - if (rejection !== undefined) { - res.writeHead(rejection) - res.end(rejection === 401 ? 'unauthorized' : 'forbidden') + const admission = this.admit(req) + if ('rejection' in admission) { + res.writeHead(admission.rejection) + res.end(admission.rejection === 401 ? 'unauthorized' : 'forbidden') return } - await bridge(req, res, fetchHandler) + await bridge(req, res, fetchHandler, undefined, (request) => { this.peers.bind(request, admission.peer) }) }, } return owner.effect( @@ -192,7 +220,7 @@ export class HostConnectionService extends Service implements HostConnectionHand } const interceptor: ConnectionRpcInterceptor = { matches, - fetchHandler: rpcFetchHandler(channel, handler), + fetchHandler: rpcFetchHandler(channel, handler, request => this.peerOf(request)), } return owner.effect(() => { if (this.interceptors.has(channel)) { @@ -209,6 +237,7 @@ export class HostConnectionService extends Service implements HostConnectionHand function rpcFetchHandler( channel: string, handler: ConnectionRpcHandler, + peerOf: PeerResolver, ): ConnectionFetchHandler { return { requestBodyMode: () => 'buffered', @@ -244,7 +273,7 @@ function rpcFetchHandler( } try { - const result = await handler(endpoint, message.payload, request.signal) + const result = await handler(endpoint, message.payload, request.signal, peerOf(request)) return fullResponse(message.rpcId, result) } catch (error) { return new Response(`handler failure: ${String(error)}`, { status: 500 }) diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index 5d4a4db957..85ab3b91b8 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -1,6 +1,7 @@ /** Generic unary RPC contracts shared by the Host and Client Connection halves. */ import type { Branded } from '@deepseek-ai/dsh-brand' +import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol' /** Correlation id minted by a caller and echoed by the Connection response. */ export type RpcId = Branded<'rpc-id'> @@ -96,11 +97,44 @@ export interface ConnectionIndexResponse { end(body?: string): unknown } -/** Handler invoked after Connection has decoded the transport envelope. */ +/** Outcome of admitting one request: the Peer it speaks for, or the status refusing it. */ +export type PeerAdmission = + | { readonly peer: PeerScope } + | { readonly rejection: 401 | 403 } + +/** Registry of the Peers this Host answers to. */ +export interface PeerRegistryHandle { + /** The operator's own Peer: every browser-cookie request and every in-process carrier speaks for it. */ + readonly operator: PeerScope + /** + * Admit one Peer on behalf of an admitter. What the Peer may do is not + * recorded here: the admitter attaches its own policy through the scope. + * @returns the opened scope; the admitter disposes it when the Peer leaves. + */ + open(): PeerScope + /** + * Record which Peer one carrier object speaks for, before it is dispatched. + * @param carrier - node request, upgrade request, WebSocket, or Fetch `Request`. + * @param peer - the Peer it was admitted as. + */ + bind(carrier: object, peer: PeerScope): void + /** + * Read the Peer a carrier object was bound to. + * @param carrier - node request, upgrade request, WebSocket, or Fetch `Request`. + * @returns the bound Peer, or undefined when nobody bound one. + */ + of(carrier: object): PeerScope | undefined +} + +/** + * Handler invoked after Connection has decoded the transport envelope. + * `peer` is the Peer the request was admitted as. + */ export type ConnectionRpcHandler = ( endpoint: string, payload: unknown, signal: AbortSignal, + peer: PeerScope, ) => Promise> /** Synchronous ownership test for one endpoint on a shared RPC channel. */ @@ -161,12 +195,14 @@ export interface HostConnectionRpc { ): () => Promise } -/** Host `ctx.connection` shape consumed by transport-independent adapters. */ +/** Host `ctx.connection` members consumed by transport-independent adapters. */ export interface HostConnectionHandle { /** Generic RPC channel registry. */ readonly rpc: HostConnectionRpc /** Exact Fetch routes for streaming or browser-native responses. */ readonly fetch: HostConnectionFetch + /** Peers this Host answers to. */ + readonly peers: PeerRegistryHandle /** * Compose exact Fetch routes and the shared-channel RPC interceptor. @@ -183,6 +219,15 @@ export interface HostConnectionHandle { */ requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection + /** + * Decide which Peer one request speaks for: a carrier bound through `peers` + * answers from its binding; every other request passes {@link requestRejection} + * and speaks for the operator. + * @param request - request headers from the HTTP or upgrade request. + * @returns the admitted Peer, or the rejection status. + */ + admit(request: ConnectionTrustRequest): PeerAdmission + /** * Authenticate one frontend index request, owning a token redirect or 401. * @param request - root or configured-index HTTP request. @@ -240,6 +285,7 @@ export interface ClientConnectionRpc { * @param endpoint - channel-relative endpoint such as `session/follow`. * @param payload - channel-owned request payload. * @param signal - caller cancellation for this logical stream. + * @param uplink - Client uplink items the Host method reads through `invocation.uplink()`. * @returns decoded stream values from the in-process carrier. */ readonly open?: ( @@ -247,5 +293,6 @@ export interface ClientConnectionRpc { endpoint: string, payload: unknown, signal: AbortSignal, + uplink?: AsyncIterable, ) => AsyncIterable } diff --git a/packages/client/connection/tests/client-apply.client.spec.ts b/packages/client/connection/tests/client-apply.client.spec.ts index 8ff914bef4..4588382d9e 100644 --- a/packages/client/connection/tests/client-apply.client.spec.ts +++ b/packages/client/connection/tests/client-apply.client.spec.ts @@ -495,9 +495,10 @@ describe('connection client apply', () => { it('exposes a worker-local Gateway stream through connection.rpc.open', async () => { ;(globalThis as Win).location = { hostname: 'preview.example' } const openStream = vi.fn>( - (endpoint, payload, signal) => (async function *(): AsyncGenerator { + (endpoint, payload, signal, uplink) => (async function *(): AsyncGenerator { signal.throwIfAborted() yield { endpoint, payload } + if (uplink !== undefined) yield* uplink })(), ) ;(globalThis as Win).__DSH_TRANSPORT__ = { @@ -521,7 +522,14 @@ describe('connection client apply', () => { 'session/follow', { args: { sessionId: 'session-1' } }, abort.signal, + undefined, ) + + const uplink = (async function *(): AsyncGenerator { yield 'typed' })() + const echoed = [] + for await (const value of open('/api', 'job/attach', { args: {} }, abort.signal, uplink)) echoed.push(value) + expect(echoed).toEqual([{ endpoint: 'job/attach', payload: { args: {} } }, 'typed']) + expect(openStream).toHaveBeenLastCalledWith('job/attach', { args: {} }, abort.signal, uplink) expect(handle.isLoopback).toBe(true) expect(() => open('/rpc', 'session/follow', {}, abort.signal)) .toThrow('worker-local streams require the /api channel') diff --git a/packages/client/connection/tsconfig.client.json b/packages/client/connection/tsconfig.client.json index ffc18472c2..a9c558d29b 100644 --- a/packages/client/connection/tsconfig.client.json +++ b/packages/client/connection/tsconfig.client.json @@ -35,6 +35,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../typert/protocol" + }, { "path": "../../util/brand" } diff --git a/packages/client/connection/tsconfig.host.json b/packages/client/connection/tsconfig.host.json index f58ca85848..b06759cb49 100644 --- a/packages/client/connection/tsconfig.host.json +++ b/packages/client/connection/tsconfig.host.json @@ -12,6 +12,7 @@ "src/http-bridge.ts", "src/index.ts", "src/loopback-hostname.ts", + "src/peer-scope.ts", "src/recovery-config.ts", "src/rpc-host.ts", "src/rpc-schema.ts", @@ -28,14 +29,20 @@ "path": "../../attachment/attachment" }, { - "path": "../../credentials/credentials" + "path": "../../core/scope" }, { "path": "../../core/session" }, + { + "path": "../../credentials/credentials" + }, { "path": "../../host/webserver" }, + { + "path": "../../typert/protocol" + }, { "path": "../../util/brand" } diff --git a/packages/experimental/webworker-runtime/src/client/client.ts b/packages/experimental/webworker-runtime/src/client/client.ts index a569e48276..9b19a3895f 100644 --- a/packages/experimental/webworker-runtime/src/client/client.ts +++ b/packages/experimental/webworker-runtime/src/client/client.ts @@ -13,6 +13,8 @@ import type { TunnelRequestId, TunnelStreamEndFrame, TunnelStreamErrorFrame, + TunnelStreamUplinkEndFrame, + TunnelStreamUplinkItemFrame, TunnelStreamItemFrame, TunnelStreamOpenFrame, } from '../transport/frames.ts' @@ -33,6 +35,13 @@ interface PendingUnary { type LogicalStreamFrame = TunnelStreamItemFrame | TunnelStreamEndFrame | TunnelStreamErrorFrame +interface UplinkPump { + /** Settles once the pump has stopped posting frames. */ + readonly done: Promise + /** Interrupt the pump, including a read blocked on the caller's iterator. */ + stop(): void +} + interface TunnelStreamFailureMarker { readonly kind: 'remote' | 'carrier' readonly code?: string @@ -234,14 +243,16 @@ export class WorkerTunnel { * @param endpoint - canonical Gateway Remote endpoint. * @param payload - decoded endpoint payload. * @param signal - logical-stream cancellation. + * @param uplink - the stream's uplink, posted as `stream-uplink-item` frames and closed with `stream-uplink-end`. * @returns decoded stream values from the worker Host. */ - async *open(endpoint: string, payload: unknown, signal: AbortSignal): AsyncGenerator { + async *open(endpoint: string, payload: unknown, signal: AbortSignal, uplink?: AsyncIterable): AsyncGenerator { signal.throwIfAborted() const id = this.nextId++ const inbox = new LogicalStreamInbox() let opened = false let terminal = false + let pump: UplinkPump | undefined const onAbort = (): void => { inbox.fail(signal.reason) } signal.addEventListener('abort', onAbort, { once: true }) this.logicalStreams.set(id, inbox) @@ -257,6 +268,7 @@ export class WorkerTunnel { message: `web-preview tunnel: failed to open Remote stream ${endpoint}`, }, { cause }) } + if (uplink !== undefined) pump = this.pumpUplink(id, uplink, signal, inbox) while (true) { const response = await inbox.next() signal.throwIfAborted() @@ -272,7 +284,56 @@ export class WorkerTunnel { signal.removeEventListener('abort', onAbort) this.logicalStreams.delete(id) this.inFlight.delete(id) + pump?.stop() if (opened && !terminal) this.abortWorkerOperation(id) + if (pump !== undefined) await pump.done + } + } + + /** + * Post the caller's uplink items for one logical stream. A failing uplink + * fails the downlink, and the enclosing `open` then aborts the worker side. + */ + private pumpUplink( + id: TunnelRequestId, + uplink: AsyncIterable, + signal: AbortSignal, + inbox: LogicalStreamInbox, + ): UplinkPump { + const interrupt = Promise.withResolvers>() + let active = true + const done = this + .forwardUplink(id, uplink[Symbol.asyncIterator](), interrupt.promise, () => active && !signal.aborted) + .catch((error: unknown) => { inbox.fail(error) }) + return { + done, + stop: () => { + active = false + interrupt.resolve({ value: undefined, done: true }) + }, + } + } + + /** Post items until the caller's iterator ends, the pump is stopped, or the page aborts the stream. */ + private async forwardUplink( + id: TunnelRequestId, + iterator: AsyncIterator, + interrupt: Promise>, + isActive: () => boolean, + ): Promise { + let exhausted = false + try { + for (;;) { + const next = await Promise.race([iterator.next(), interrupt]) + if (!isActive()) return + if (next.done === true) break + this.worker.postMessage({ t: 'stream-uplink-item', id, value: next.value } satisfies TunnelStreamUplinkItemFrame) + } + exhausted = true + this.worker.postMessage({ t: 'stream-uplink-end', id } satisfies TunnelStreamUplinkEndFrame) + } finally { + // A generator blocked in next() completes this return only once it yields, so it is not awaited. + if (!exhausted) void Promise.resolve().then(() => iterator.return?.()).catch(() => undefined) } } diff --git a/packages/experimental/webworker-runtime/src/client/index.ts b/packages/experimental/webworker-runtime/src/client/index.ts index 467f915902..1fff274cfd 100644 --- a/packages/experimental/webworker-runtime/src/client/index.ts +++ b/packages/experimental/webworker-runtime/src/client/index.ts @@ -27,7 +27,12 @@ export { interface ClientTransportGlobal { __DSH_TRANSPORT__?: { fetch: TunnelFetch - openStream: (endpoint: string, payload: unknown, signal: AbortSignal) => AsyncIterable + openStream: ( + endpoint: string, + payload: unknown, + signal: AbortSignal, + uplink?: AsyncIterable, + ) => AsyncIterable loadBundle: (url: string) => Promise /** The page spawned the worker the Host runs in, so the page owns it. */ ownsHost: boolean @@ -151,7 +156,7 @@ export async function connectWorkerHost(worker: Worker, options?: WorkerHostConn const payload = await tunnel.bootPayload() ;(globalThis as ClientTransportGlobal).__DSH_TRANSPORT__ = { fetch: (input, init) => tunnel.fetch(input, init), - openStream: (endpoint, payload, signal) => tunnel.open(endpoint, payload, signal), + openStream: (endpoint, payload, signal, uplink) => tunnel.open(endpoint, payload, signal, uplink), loadBundle: (url: string) => tunnel.loadBundle(url), // The host lives in a worker this page spawned: the page owns it, so // the privileged surface stays reachable off loopback authorities. diff --git a/packages/experimental/webworker-runtime/src/index.ts b/packages/experimental/webworker-runtime/src/index.ts index fd3e3f563b..9bb306433c 100644 --- a/packages/experimental/webworker-runtime/src/index.ts +++ b/packages/experimental/webworker-runtime/src/index.ts @@ -11,8 +11,8 @@ export { type TunnelAbortFrame, type TunnelInboundFrame, type TunnelOutboundFrame, type TunnelRequestFrame, type TunnelRequestId, type TunnelResponseChunkFrame, type TunnelResponseEndFrame, type TunnelResponseErrorFrame, type TunnelResponseFrame, type TunnelResponseHeadFrame, - type TunnelStreamEndFrame, type TunnelStreamErrorFrame, type TunnelStreamItemFrame, - type TunnelStreamOpenFrame, + type TunnelStreamEndFrame, type TunnelStreamErrorFrame, type TunnelStreamUplinkEndFrame, + type TunnelStreamUplinkItemFrame, type TunnelStreamItemFrame, type TunnelStreamOpenFrame, } from './transport/frames.ts' export { DEFAULT_CONDITIONS, requireActiveModuleLoader, setActiveModuleLoader, WorkerModuleLoader, diff --git a/packages/experimental/webworker-runtime/src/transport/frames.ts b/packages/experimental/webworker-runtime/src/transport/frames.ts index 01d399d4e8..744338be1d 100644 --- a/packages/experimental/webworker-runtime/src/transport/frames.ts +++ b/packages/experimental/webworker-runtime/src/transport/frames.ts @@ -25,6 +25,19 @@ export interface TunnelStreamOpenFrame { readonly payload: unknown } +/** One uplink item for an open Remote stream; `value` absent and `undefined` are equivalent. */ +export interface TunnelStreamUplinkItemFrame { + readonly t: 'stream-uplink-item' + readonly id: TunnelRequestId + readonly value?: unknown +} + +/** Page-side half-close of a Remote stream's uplink. */ +export interface TunnelStreamUplinkEndFrame { + readonly t: 'stream-uplink-end' + readonly id: TunnelRequestId +} + /** Page-side cancellation of an in-flight request or stream. */ export interface TunnelAbortFrame { readonly t: 'abort' @@ -47,6 +60,8 @@ export type TunnelInboundFrame = | TunnelInitFrame | TunnelRequestFrame | TunnelStreamOpenFrame + | TunnelStreamUplinkItemFrame + | TunnelStreamUplinkEndFrame | TunnelAbortFrame /** Complete response for unary requests and static files. */ @@ -153,6 +168,8 @@ export function parseInboundFrame(data: unknown): TunnelInboundFrame { throw new Error(`webworker tunnel: frame has no usable id: ${JSON.stringify(frame.id)}`) } if (frame.t === 'abort') return { t: 'abort', id } + if (frame.t === 'stream-uplink-end') return { t: 'stream-uplink-end', id } + if (frame.t === 'stream-uplink-item') return { t: 'stream-uplink-item', id, value: frame.value } if (frame.t === 'stream-open') { if (typeof frame.endpoint !== 'string' || frame.endpoint.length === 0) { throw new Error(`webworker tunnel: stream ${String(id)} needs a non-empty endpoint`) diff --git a/packages/experimental/webworker-runtime/src/transport/tunnel.ts b/packages/experimental/webworker-runtime/src/transport/tunnel.ts index dc6608f975..b94f927a9d 100644 --- a/packages/experimental/webworker-runtime/src/transport/tunnel.ts +++ b/packages/experimental/webworker-runtime/src/transport/tunnel.ts @@ -91,10 +91,11 @@ export interface TunnelSeams { readonly directFetch: (request: Request) => Promise /** Boot payload for `GET /__boot__`: the structured index injection table. */ readonly bootPayload: () => unknown - /** Open one decoded Gateway Remote stream without another network carrier. */ + /** Open one decoded Gateway Remote stream without another network carrier; `uplink` carries the page's items. */ readonly openStream: ( endpoint: string, payload: unknown, + uplink: AsyncIterable, signal: AbortSignal, ) => Promise> /** Convert a Gateway stream failure to stable Client fields. */ @@ -173,6 +174,54 @@ class BufferedSink { } } +/** Uplink items of one worker-local logical stream, read once by the Host method as its `uplink`. */ +class TunnelUplink implements AsyncIterable, AsyncIterator { + private readonly items: unknown[] = [] + private ended = false + private closed = false + private wake: (() => void) | undefined + + push(value: unknown): void { + if (this.ended || this.closed) return + this.items.push(value) + this.signal() + } + + /** Page half-close or stream end; buffered items still drain. */ + end(): void { + this.ended = true + this.signal() + } + + [Symbol.asyncIterator](): AsyncIterator { + return this + } + + async next(): Promise> { + while (true) { + if (this.closed) return { value: undefined, done: true } + if (this.items.length > 0) return { value: this.items.shift(), done: false } + if (this.ended) return { value: undefined, done: true } + if (this.wake !== undefined) throw new Error('webworker tunnel: stream uplink has one pending read') + await new Promise((resolve) => { this.wake = resolve }) + } + } + + /** The Host stopped reading: buffered and later items are dropped. */ + return(): Promise> { + this.closed = true + this.items.length = 0 + this.signal() + return Promise.resolve({ value: undefined, done: true }) + } + + private signal(): void { + const wake = this.wake + this.wake = undefined + wake?.() + } +} + /** One tunnel per worker; wire {@link TunnelServer.handleMessage} to `onmessage` first. */ export class TunnelServer { private readonly port: TunnelPort @@ -181,6 +230,8 @@ export class TunnelServer { private readonly unaryApiLane: 'route' | 'direct' private readonly queue: QueuedFrame[] = [] private readonly inFlight = new Map() + /** Uplinks of accepted `stream-open` frames, buffering items that arrive before or while the stream serves. */ + private readonly uplinks = new Map() private seams: TunnelSeams | undefined private failure: string | undefined private listener: RequestListener | undefined @@ -203,9 +254,18 @@ export class TunnelServer { // one reaching a live server is a client double-connect. throw new Error('webworker tunnel: duplicate init frame; the tunnel is already open') } + if (frame.t === 'stream-uplink-item') { + this.uplinks.get(frame.id)?.push(frame.value) + return + } + if (frame.t === 'stream-uplink-end') { + this.uplinks.get(frame.id)?.end() + return + } if (frame.t === 'abort') { this.inFlight.get(frame.id)?.abort() this.inFlight.delete(frame.id) + this.dropUplink(frame.id) // A request still parked in the boot queue must not run after its // caller gave up; serve() would otherwise execute it post-boot. const queued = this.queue.findIndex(request => request.id === frame.id) @@ -213,6 +273,7 @@ export class TunnelServer { return } if (this.failure !== undefined) { this.refuse(frame, this.failure); return } + if (frame.t === 'stream-open') this.uplinks.set(frame.id, new TunnelUplink()) if (this.seams === undefined) { this.queue.push(frame) return @@ -247,6 +308,7 @@ export class TunnelServer { private refuse(frame: QueuedFrame, message: string): void { if (frame.t === 'stream-open') { + this.dropUplink(frame.id) this.send({ t: 'stream-error', id: frame.id, @@ -276,10 +338,11 @@ export class TunnelServer { return } const seams = this.seams + const uplink = this.uplinks.get(frame.id) ?? new TunnelUplink() const controller = new AbortController() this.inFlight.set(frame.id, { abort: () => { controller.abort() } }) try { - const source = await seams.openStream(frame.endpoint, frame.payload, controller.signal) + const source = await seams.openStream(frame.endpoint, frame.payload, uplink, controller.signal) for await (const value of source) { if (controller.signal.aborted) return this.send({ t: 'stream-item', id: frame.id, value }) @@ -296,9 +359,17 @@ export class TunnelServer { } } finally { this.inFlight.delete(frame.id) + this.dropUplink(frame.id) } } + /** The stream is over: settle any Host read still waiting on its uplink and stop buffering. */ + private dropUplink(id: TunnelRequestId): void { + const uplink = this.uplinks.get(id) + this.uplinks.delete(id) + uplink?.end() + } + private sinkFor(id: TunnelRequestId): ResponseSink { const send = this.send.bind(this) const inFlight = this.inFlight diff --git a/packages/experimental/webworker-runtime/src/worker-host.ts b/packages/experimental/webworker-runtime/src/worker-host.ts index 70b70ac9ed..3c96174f8d 100644 --- a/packages/experimental/webworker-runtime/src/worker-host.ts +++ b/packages/experimental/webworker-runtime/src/worker-host.ts @@ -256,7 +256,9 @@ export function createWorkerHost(options: WorkerHostOptions): WorkerHost { tunnel.serve({ directFetch: (request: Request) => handler.fetch(request), bootPayload: () => readBootPayload(ctx), - openStream: typertGateway.wireStream.open, + // The worker tree serves its own page: every stream speaks for the operator. + openStream: (endpoint, payload, uplink, signal) => + typertGateway.wireStream.open(endpoint, payload, uplink, undefined, signal), streamFailure: typertGateway.wireStream.failure, }) } catch (reason) { diff --git a/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts b/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts index 4113bfd25b..0ee1b9f94f 100644 --- a/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts +++ b/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts @@ -21,6 +21,17 @@ describe('tunnel init frame', () => { }) }) +describe('tunnel uplink frames', () => { + it('parses uplink items with and without a value and the uplink end', () => { + expect(parseInboundFrame({ t: 'stream-uplink-item', id: 7, value: { line: 'ls' } })) + .toEqual({ t: 'stream-uplink-item', id: 7, value: { line: 'ls' } }) + expect(parseInboundFrame({ t: 'stream-uplink-item', id: 'seven' })) + .toEqual({ t: 'stream-uplink-item', id: 'seven', value: undefined }) + expect(parseInboundFrame({ t: 'stream-uplink-end', id: 7 })).toEqual({ t: 'stream-uplink-end', id: 7 }) + expect(() => parseInboundFrame({ t: 'stream-uplink-end' })).toThrow('frame has no usable id') + }) +}) + describe('tunnel request bodies', () => { it('accepts ArrayBuffer, Blob, and ReadableStream bodies and rejects other values', () => { const bytes = Uint8Array.of(1, 2).buffer diff --git a/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts b/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts index c309e99e09..e0403834f9 100644 --- a/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts +++ b/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts @@ -276,3 +276,71 @@ function stubWorker(): { dshRemoteStreamFailure: { kind: 'carrier' }, }) } + +// A stream posts each uplink item, then the uplink end, under the stream's id. +{ + const { worker, sent, deliver } = stubWorker() + const tunnel = new WorkerTunnel(worker) + const uplink = (async function *(): AsyncGenerator { + yield 'a' + yield 'b' + })() + const stream = tunnel.open('job/attach', { args: {} }, new AbortController().signal, uplink)[Symbol.asyncIterator]() + const first = stream.next() + await settled(() => sent.length === 4) + check('a stream posts uplink items and the uplink end', sent, [ + { t: 'stream-open', id: 1, endpoint: 'job/attach', payload: { args: {} } }, + { t: 'stream-uplink-item', id: 1, value: 'a' }, + { t: 'stream-uplink-item', id: 1, value: 'b' }, + { t: 'stream-uplink-end', id: 1 }, + ]) + deliver({ t: 'stream-item', id: 1, value: 'echo:a' }) + check('the downlink still yields Host items', await first, { value: 'echo:a', done: false }) + const ended = stream.next() + deliver({ t: 'stream-end', id: 1 }) + check('a duplex stream ends without an abort frame', [await ended, sent.length], [{ done: true, value: undefined }, 4]) +} + +// A failing uplink fails the downlink and aborts the worker side of the stream. +{ + const { worker, sent } = stubWorker() + const tunnel = new WorkerTunnel(worker) + const uplink = (async function *(): AsyncGenerator { + yield 'a' + throw new Error('uplink exploded') + })() + const failure = await tunnel.open('job/attach', {}, new AbortController().signal, uplink)[Symbol.asyncIterator]().next() + .then(() => 'resolved', (error: unknown) => (error as Error).message) + check('an uplink failure fails the downlink', failure, 'uplink exploded') + check('an uplink failure aborts the worker stream', sent.map(frame => frame.t), ['stream-open', 'stream-uplink-item', 'abort']) +} + +// The downlink ending first stops the uplink pump and releases the caller's iterator. +{ + const { worker, sent, deliver } = stubWorker() + const tunnel = new WorkerTunnel(worker) + let released = false + const uplink: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + next: () => new Promise>(() => {}), + return: async (): Promise> => { + released = true + return { value: undefined, done: true } + }, + }), + } + const stream = tunnel.open('job/attach', {}, new AbortController().signal, uplink)[Symbol.asyncIterator]() + const ended = stream.next() + deliver({ t: 'stream-end', id: 1 }) + check('the downlink ends while the uplink is idle', await ended, { done: true, value: undefined }) + await settled(() => released) + check('the caller iterator is released without an uplink end', [released, sent.map(frame => frame.t)], [true, ['stream-open']]) +} + +/** Wait, in small hops, for a condition that a concurrent pump settles. */ +async function settled(condition: () => boolean): Promise { + for (let attempt = 0; attempt < 200 && !condition(); attempt += 1) { + await new Promise((resolve) => { setTimeout(resolve, 1) }) + } + if (!condition()) throw new Error('fixture condition did not settle') +} diff --git a/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts b/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts index 7ca4ba6260..68c956e467 100644 --- a/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts +++ b/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts @@ -137,8 +137,8 @@ describe('worker tunnel logical streams', () => { }) expect(frames).toEqual([]) - server.serve(seams(async (endpoint, payload, signal) => { - seen.push(endpoint, payload, signal) + server.serve(seams(async (endpoint, payload, uplink, signal) => { + seen.push(endpoint, payload, uplink, signal) return (async function *(): AsyncGenerator { yield { type: 'baseline' } yield { type: 'event', seq: 1 } @@ -155,15 +155,103 @@ describe('worker tunnel logical streams', () => { expect(seen).toEqual([ 'session/follow', { args: { sessionId: 'session-1' } }, + expect.any(Object), expect.any(AbortSignal), ]) }) + it('buffers uplink items until the Host reads them and ends the uplink on half-close', async () => { + const { server, frames } = harness() + server.handleMessage({ t: 'stream-open', id: 6, endpoint: 'job/attach', payload: { args: {} } }) + server.handleMessage({ t: 'stream-uplink-item', id: 6, value: 'a' }) + server.serve(seams(async (_endpoint, _payload, uplink) => (async function *(): AsyncGenerator { + for await (const item of uplink) yield `echo:${String(item)}` + })())) + await vi.waitFor(() => { expect(frames).toEqual([{ t: 'stream-item', id: 6, value: 'echo:a' }]) }) + + server.handleMessage({ t: 'stream-uplink-item', id: 6, value: 'b' }) + server.handleMessage({ t: 'stream-uplink-item', id: 99, value: 'no such stream' }) + server.handleMessage({ t: 'stream-uplink-end', id: 99 }) + server.handleMessage({ t: 'stream-uplink-end', id: 6 }) + await vi.waitFor(() => { + expect(frames).toEqual([ + { t: 'stream-item', id: 6, value: 'echo:a' }, + { t: 'stream-item', id: 6, value: 'echo:b' }, + { t: 'stream-end', id: 6 }, + ]) + }) + }) + + it('drops uplink items once the Host stops reading them', async () => { + const { server, frames } = harness() + const outcome = Promise.withResolvers() + server.serve(seams(async (_endpoint, _payload, uplink) => { + const iterator = uplink[Symbol.asyncIterator]() + return (async function *(): AsyncGenerator { + yield 'ready' + const first = await iterator.next() + await iterator.return?.() + outcome.resolve({ first, afterReturn: await iterator.next() }) + })() + })) + server.handleMessage({ t: 'stream-open', id: 7, endpoint: 'job/attach', payload: {} }) + await vi.waitFor(() => { expect(frames).toContainEqual({ t: 'stream-item', id: 7, value: 'ready' }) }) + server.handleMessage({ t: 'stream-uplink-item', id: 7, value: 'one' }) + server.handleMessage({ t: 'stream-uplink-item', id: 7, value: 'dropped' }) + await expect(outcome.promise).resolves.toEqual({ + first: { value: 'one', done: false }, + afterReturn: { value: undefined, done: true }, + }) + await vi.waitFor(() => { expect(frames).toContainEqual({ t: 'stream-end', id: 7 }) }) + }) + + it('ends a pending uplink read when the page aborts the stream', async () => { + const { server } = harness() + const read = Promise.withResolvers() + const opened = Promise.withResolvers() + server.serve(seams(async (_endpoint, _payload, uplink, signal) => { + uplink[Symbol.asyncIterator]().next().then(read.resolve, read.resolve) + opened.resolve(undefined) + return (async function *(): AsyncGenerator { + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + })() + })) + server.handleMessage({ t: 'stream-open', id: 8, endpoint: 'job/attach', payload: {} }) + await opened.promise + server.handleMessage({ t: 'abort', id: 8 }) + await expect(read.promise).resolves.toEqual({ value: undefined, done: true }) + }) + + it('fails a stream whose Host reads the uplink twice concurrently', async () => { + const { server, frames } = harness() + server.serve(seams(async (_endpoint, _payload, uplink) => { + const iterator = uplink[Symbol.asyncIterator]() + void iterator.next() + await iterator.next() + return (async function *(): AsyncGenerator {})() + })) + server.handleMessage({ t: 'stream-open', id: 9, endpoint: 'job/attach', payload: {} }) + await vi.waitFor(() => { + expect(frames).toEqual([{ + t: 'stream-error', + id: 9, + failure: { + kind: 'remote', + code: 'fixture-stream-failed', + message: 'webworker tunnel: stream uplink has one pending read', + details: { fixture: true }, + }, + }]) + }) + }) + it('cancels one logical stream without emitting a terminal frame', async () => { const { server, frames } = harness() const opened = Promise.withResolvers() const stopped = Promise.withResolvers() - server.serve(seams(async (_endpoint, _payload, signal) => { + server.serve(seams(async (_endpoint, _payload, _uplink, signal) => { opened.resolve(signal) return (async function *(): AsyncGenerator { yield 'ready' diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index df7a9d8900..d92592d81a 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -515,7 +515,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ClientConnectionRpc', - declaration: 'export interface ClientConnectionRpc {\n call(channel: string, endpoint: string, payload: unknown, signal?: AbortSignal): Promise>;\n readonly open?: (channel: string, endpoint: string, payload: unknown, signal: AbortSignal) => AsyncIterable;\n}', + declaration: 'export interface ClientConnectionRpc {\n call(channel: string, endpoint: string, payload: unknown, signal?: AbortSignal): Promise>;\n readonly open?: (channel: string, endpoint: string, payload: unknown, signal: AbortSignal, uplink?: AsyncIterable) => AsyncIterable;\n}', }, { name: 'ClientRemote', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 69e75dd00b..637a6935af 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -3068,7 +3068,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Resolve strict generated definitions or conservative SRC markers against current Cordis Services and Typert providers.', methods: [ { - signature: 'readonly wireStream: TypertGatewayWireStream = { open: (endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal), failure: error => rpcError(error), }', + signature: 'readonly wireStream: TypertGatewayWireStream = { open: (endpoint, payload, uplink, signal) => this.openWireStream(endpoint, payload, uplink, signal, new AbortController()), failure: error => rpcError(error), }', description: 'Carrier adapter shared by the WebSocket mux and local Host transports.', parameters: [], }, @@ -3087,8 +3087,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async stream(request: InvokeRemoteRequest): Promise>', - description: 'Open one live stream Remote method without assuming a physical carrier.', - parameters: [{ name: 'request', description: 'decoded endpoint and named wire arguments.' }], + description: 'Open one live stream or duplex Remote method without assuming a physical carrier.', + parameters: [{ name: 'request', description: 'decoded endpoint, named wire arguments, and the duplex uplink when the method reads one.' }], returns: 'a cancellation-aware iterable over the business results.', }, ], @@ -4925,7 +4925,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'InvocationDescriptor', - declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly mode?: \'stream\';\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypertCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly cancellation?: {\n readonly parameter: \'signal\';\n };\n readonly result: TypertCodec;\n readonly sourceLocation?: InvocationSourceLocation;\n}', + declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly mode?: \'stream\' | \'duplex\';\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypertCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly uplink?: {\n readonly parameter: \'uplink\';\n readonly codec: TypertCodec;\n };\n readonly cancellation?: {\n readonly parameter: \'signal\';\n };\n readonly result: TypertCodec;\n readonly sourceLocation?: InvocationSourceLocation;\n}', }, { name: 'InvocationParameterDescriptor', @@ -4937,7 +4937,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'InvokeRemoteRequest', - declaration: 'export interface InvokeRemoteRequest {\n readonly namespace: string;\n readonly method: string;\n readonly args: Readonly>;\n readonly signal?: AbortSignal;\n}', + declaration: 'export interface InvokeRemoteRequest {\n readonly namespace: string;\n readonly method: string;\n readonly args: Readonly>;\n readonly uplink?: AsyncIterable;\n readonly signal?: AbortSignal;\n}', }, { name: 'JobDoneListener', @@ -6933,7 +6933,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TypertGatewayWireStream', - declaration: 'export interface TypertGatewayWireStream {\n readonly open: (endpoint: string, payload: unknown, signal: AbortSignal) => Promise>;\n readonly failure: (error: unknown) => {\n readonly code: string;\n readonly message: string;\n readonly details: object;\n };\n}', + declaration: 'export interface TypertGatewayWireStream {\n readonly open: (endpoint: string, payload: unknown, uplink: AsyncIterable, signal: AbortSignal) => Promise>;\n readonly failure: (error: unknown) => {\n readonly code: string;\n readonly message: string;\n readonly details: object;\n };\n}', }, { name: 'TypertMemberModel', diff --git a/packages/test-support/remote-mock/src/index.ts b/packages/test-support/remote-mock/src/index.ts index d7c1850c2e..b6327ba0e2 100644 --- a/packages/test-support/remote-mock/src/index.ts +++ b/packages/test-support/remote-mock/src/index.ts @@ -10,7 +10,7 @@ */ export { RemoteMock, ok } from './remote-mock.ts' export type { OpenStreams, RemoteMockOptions, RemoteTable, StreamFilter, UnaryRuleFn } from './remote-mock.ts' -export { frames, openStream } from './streams.ts' +export { frames, openStream, streamHandle, streamMethod } from './streams.ts' export type { StreamHandle, StreamScript } from './streams.ts' export type { LoggedCall, LoggedMiss, LoggedStream, MockLog } from './log.ts' export type { MockedRemote } from './remote-proxy.ts' diff --git a/packages/test-support/remote-mock/src/remote-mock.ts b/packages/test-support/remote-mock/src/remote-mock.ts index fca6a7a09c..5f44749c01 100644 --- a/packages/test-support/remote-mock/src/remote-mock.ts +++ b/packages/test-support/remote-mock/src/remote-mock.ts @@ -156,7 +156,7 @@ export class RemoteMock { // The carrier contract names the result envelope; the registered value is taken as that envelope unchecked. return value as ConnectionRpcResult }, - open: (_channel, endpoint, payload, signal) => this.open(endpoint, argsOf(endpoint, payload), signal), + open: (_channel, endpoint, payload, signal, uplink) => this.open(endpoint, argsOf(endpoint, payload), signal, uplink), } private constructor() {} @@ -282,11 +282,17 @@ export class RemoteMock { * @param endpoint - endpoint. * @param args - positional args. * @param signal - consumer cancellation. + * @param uplink - the stream's uplink, passed positionally after the args as the whole-client proxies send it. * @returns the controlled script stream or the native override's caller-owned iterable. * @throws {Error} when the default runs without a registered script (logged as unmatched). */ - open(endpoint: string, args: readonly unknown[], signal: AbortSignal): AsyncIterable { - return this.streamMock(endpoint)(...args, signal) as AsyncIterable + open( + endpoint: string, + args: readonly unknown[], + signal: AbortSignal, + uplink?: AsyncIterable, + ): AsyncIterable { + return this.streamMock(endpoint)(...args, ...(uplink === undefined ? [] : [uplink]), signal) as AsyncIterable } private streamMock(endpoint: string): Mock { @@ -295,20 +301,27 @@ export class RemoteMock { mock = fn((...values: readonly unknown[]) => { const args = [...values] const signal = args.at(-1) instanceof AbortSignal ? args.pop() as AbortSignal : new AbortController().signal - return this.openScript(endpoint, args, signal) + // Wire args are JSON values, so a trailing async iterable is the stream's uplink. + const uplink = isAsyncIterable(args.at(-1)) ? args.pop() as AsyncIterable : EMPTY_UPLINK + return this.openScript(endpoint, args, signal, uplink) }) this.streamMocks.set(endpoint, mock) } return mock } - private openScript(endpoint: string, args: readonly unknown[], signal: AbortSignal): AsyncIterable { + private openScript( + endpoint: string, + args: readonly unknown[], + signal: AbortSignal, + uplink: AsyncIterable, + ): AsyncIterable { const script = this.scripts.get(endpoint) if (script === undefined) { this.logStore.miss(endpoint, 'stream') throw new Error(this.noRuleMessage(endpoint)) } - const stream = new MockStream(this.logStore.stream(endpoint, args), signal) + const stream = new MockStream(this.logStore.stream(endpoint, args), signal, uplink) this.live.push(stream) this.wakeOpened(endpoint) stream.run(script, args) @@ -351,6 +364,14 @@ function isRuleFn(rule: unknown): rule is UnaryRuleFn { return typeof rule === 'function' } +const EMPTY_UPLINK: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ next: () => Promise.resolve({ value: undefined, done: true }) }), +} + +function isAsyncIterable(value: unknown): value is AsyncIterable { + return typeof value === 'object' && value !== null && Symbol.asyncIterator in value +} + /** A rule's synchronous throw becomes a rejection so the call settles through one path. */ function answerOf(rule: UnaryRuleFn, args: readonly unknown[]): unknown { try { diff --git a/packages/test-support/remote-mock/src/streams.ts b/packages/test-support/remote-mock/src/streams.ts index 0090087720..0205e2ac8a 100644 --- a/packages/test-support/remote-mock/src/streams.ts +++ b/packages/test-support/remote-mock/src/streams.ts @@ -1,7 +1,40 @@ /** Stream scripts and the pushable, abort-aware stream a script drives. */ +import type { RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol/client' import type { StreamRecord } from './log.ts' +/** + * Type a fake stream method's result as the handle a generated Client method + * returns. The uplink members are inert: a fake built on it drives the downlink only. + * @param source - downlink items the fake yields. + * @returns a handle that iterates `source`. + */ +export function streamHandle(source: AsyncIterable): RemoteStreamHandle { + return { + [Symbol.asyncIterator]: () => source[Symbol.asyncIterator](), + send: () => undefined, + end: () => undefined, + dispose: () => undefined, + } +} + +/** Downlink item type of one generated stream method. */ +type StreamItem = Method extends (...args: never[]) => RemoteStreamHandle ? Out : never + +/** + * Lift a fake stream method written as an async generator function into the + * signature of the generated method it stands in for. + * @param method - fake taking the method's arguments and yielding its downlink items. + * @returns the fake returning {@link streamHandle} of each call. + */ +export function streamMethod RemoteStreamHandle>( + method: (...args: Parameters) => AsyncIterable>, +): Method { + const lifted = (...args: Parameters): RemoteStreamHandle, unknown> => + streamHandle(method(...args)) + return lifted as Method +} + /** Test-side controls over one open stream. */ export interface StreamHandle { /** Queue one item for the consumer. */ @@ -12,6 +45,8 @@ export interface StreamHandle { fail(error: Error): void /** Aborts when the opening signal aborts or the consumer returns early. */ readonly signal: AbortSignal + /** Uplink items the caller passed with the open; an immediately ended iterable when it passed none. */ + readonly uplink: AsyncIterable } /** @@ -66,8 +101,13 @@ export class MockStream implements StreamHandle, AsyncIterable { /** * @param record - log entry this stream updates. * @param sourceSignal - cancellation from the caller that opened the stream. + * @param uplink - the caller's uplink, exposed to the script unchanged. */ - constructor(readonly record: StreamRecord, private readonly sourceSignal: AbortSignal) { + constructor( + readonly record: StreamRecord, + private readonly sourceSignal: AbortSignal, + readonly uplink: AsyncIterable, + ) { if (sourceSignal.aborted) this.cancel(sourceSignal.reason) else sourceSignal.addEventListener('abort', this.onAbort, { once: true }) } diff --git a/packages/test-support/remote-mock/tests/proxy.client.spec.ts b/packages/test-support/remote-mock/tests/proxy.client.spec.ts index ba068d70d2..b089522c48 100644 --- a/packages/test-support/remote-mock/tests/proxy.client.spec.ts +++ b/packages/test-support/remote-mock/tests/proxy.client.spec.ts @@ -1,6 +1,6 @@ /** Native namespace mocks shared by local callers and the Connection carrier. */ import { describe, expect, it, onTestFinished, vi } from 'vitest' -import { RemoteMock, frames, ok, openStream, type StreamHandle } from '../src/index.ts' +import { RemoteMock, frames, ok, openStream, streamHandle, type StreamHandle } from '../src/index.ts' const describeValue = (hasDocument: boolean) => ok({ writable: true, hasDocument, namespaces: [] }) const baseline = { type: 'baseline' as const, value: { queues: {}, jobs: {}, projections: {} } } @@ -230,8 +230,9 @@ describe('RemoteMock.remote stream proxies', () => { consumed = true yield baseline } - const native = response() - onTestFinished(async () => { await native.return(undefined) }) + const generator = response() + const native = streamHandle(generator) + onTestFinished(async () => { await generator.return(undefined) }) control.mockReturnValueOnce(native) const overridden = mock.open('session/control', [], signal) expect(overridden).toBe(native) diff --git a/packages/test-support/remote-mock/tests/rpc.client.spec.ts b/packages/test-support/remote-mock/tests/rpc.client.spec.ts index 2eb0a620bc..c712f11ac7 100644 --- a/packages/test-support/remote-mock/tests/rpc.client.spec.ts +++ b/packages/test-support/remote-mock/tests/rpc.client.spec.ts @@ -18,6 +18,25 @@ describe('RemoteMock.rpc', () => { expect(mock.log.streams('session/control').map(open => open.args)).toEqual([[{ since: 1 }]]) }) + it('hands the uplink to the script and keeps it out of the logged args', async () => { + const mock = RemoteMock.create().stream('job/attach', async (args, stream) => { + for await (const item of stream.uplink) stream.push(`${String(args[0])}:${String(item)}`) + stream.end() + }) + const drain = async (source: AsyncIterable): Promise => { + const items: unknown[] = [] + for await (const item of source) items.push(item) + return items + } + const uplink = (values: readonly string[]): AsyncIterable => (async function *() { yield* values })() + + await expect(drain(mock.rpc.open!('/api', 'job/attach', { args: ['job-1'] }, idle(), uplink(['a', 'b'])))) + .resolves.toEqual(['job-1:a', 'job-1:b']) + await expect(drain(mock.open('job/attach', ['job-2', uplink(['c'])], idle()))).resolves.toEqual(['job-2:c']) + await expect(drain(mock.open('job/attach', ['job-3'], idle()))).resolves.toEqual([]) + expect(mock.log.streams('job/attach').map(open => open.args)).toEqual([['job-1'], ['job-2'], ['job-3']]) + }) + it('rejects malformed payloads and unmatched endpoints, logging the miss', async () => { const mock = RemoteMock.create() await expect(mock.rpc.call('/api', 'a/b', 'bare')).rejects.toThrow('remote-mock: payload of a/b must be { args: unknown[] | object }') diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index eebf7fbc29..7388d6c6b1 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -1198,7 +1198,17 @@ class FaceAnalyzer { } const mode = invocation.kind === 'direct' ? invocation.mode : undefined - const resultType = this.remoteResultType(method, mode) + const { result: resultType, uplink: uplinkType } = this.remoteResultType(method, mode) + const uplink: InvocationModel['uplink'] = uplinkType === undefined + ? undefined + : { + boundary: this.remoteBoundary( + uplinkType, + `${registration.name}#${binding.namespace}/${exportedMethod}:uplink`, + false, + 'undefined', + ), + } return { id: `${registration.name}#${binding.namespace}/${exportedMethod}`, service: binding.service, @@ -1209,6 +1219,7 @@ class FaceAnalyzer { invocation: receiver, ...(scope === undefined ? {} : { scope }), parameters, + ...(uplink === undefined ? {} : { uplink }), ...(cancellation === undefined ? {} : { cancellation }), result: this.remoteBoundary( resultType, @@ -1337,12 +1348,11 @@ class FaceAnalyzer { } const [property] = argument.properties if (property === undefined) this.fail(argument, 'Remote() options must contain exactly mode: "stream"') - if (!ts.isPropertyAssignment(property) - || memberName(property.name) !== 'mode' - || stringLiteralValue(property.initializer) !== 'stream') { - this.fail(property, 'Remote() options must contain exactly mode: "stream"') - } - marker = { kind: 'direct', mode: 'stream' } + const mode = ts.isPropertyAssignment(property) && memberName(property.name) === 'mode' + ? stringLiteralValue(property.initializer) + : undefined + if (mode !== 'stream') this.fail(property, 'Remote() options must contain exactly mode: "stream"') + marker = { kind: 'direct', mode } } } else if (ts.isCallExpression(expression) && this.isTypeMetaSymbol(expression.expression, 'RemoteScope')) { @@ -1368,27 +1378,43 @@ class FaceAnalyzer { return found } - private remoteResultType(method: ts.MethodDeclaration, mode?: 'stream'): ts.TypeNode { + /** + * The item types a Remote method's authored return type declares. Unary + * methods unwrap `Promise`; stream methods unwrap `Iterable`, + * `AsyncIterable`, or the protocol's `RemoteStream`, whose + * second type argument is the uplink item type unless it is `never`. + */ + private remoteResultType( + method: ts.MethodDeclaration, + mode?: 'stream', + ): { readonly result: ts.TypeNode; readonly uplink?: ts.TypeNode } { const authored = this.requiredType(method, method.type, 'return') if (ts.isTypeReferenceNode(authored)) { const symbol = this.checker.getSymbolAtLocation(authored.typeName) const resolved = symbol === undefined ? undefined : this.resolveSymbol(symbol) - const resultType = authored.typeArguments?.[0] - const wrappers = mode === 'stream' ? ['Iterable', 'AsyncIterable'] : ['Promise'] const declaration = resolved === undefined ? undefined : preferredDeclaration(resolved) - if (resolved !== undefined - && wrappers.includes(resolved.name) - && resultType !== undefined - && authored.typeArguments?.length === 1 - && declaration !== undefined - && isStandardLibraryFile(declaration.getSourceFile().fileName)) { - return resultType + const [result, uplink] = authored.typeArguments ?? [] + const arity = authored.typeArguments?.length ?? 0 + if (resolved !== undefined && declaration !== undefined && result !== undefined) { + const standard = isStandardLibraryFile(declaration.getSourceFile().fileName) + const wrappers = mode === undefined ? ['Promise'] : ['Iterable', 'AsyncIterable'] + if (standard && wrappers.includes(resolved.name) && arity === 1) return { result } + if (mode !== undefined + && resolved.name === 'RemoteStream' + && this.isTypeMetaSymbol(authored.typeName, 'RemoteStream') + && arity <= 2) { + return uplink === undefined || this.isNeverType(uplink) ? { result } : { result, uplink } + } } } - if (mode === 'stream') { - this.fail(method, 'stream Remote methods must return Iterable or AsyncIterable') + if (mode !== undefined) { + this.fail(method, 'stream Remote methods must return Iterable, AsyncIterable, or RemoteStream') } - return authored + return { result: authored } + } + + private isNeverType(type: ts.TypeNode): boolean { + return (this.checker.getTypeFromTypeNode(type).flags & ts.TypeFlags.Never) !== 0 } private isGlobalAbortSignal(type: ts.TypeNode): boolean { diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index 018184d5b5..830b0f9010 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -23,6 +23,9 @@ import type { } from './model.ts' import { TypeGraphRenderer } from './renderer.ts' +/** Protocol entry that exports the handle a generated Client stream method returns. */ +const REMOTE_CLIENT_PROTOCOL = '@deepseek-ai/dsh-typert-protocol/client' + /** Failure to project a modeled construct into an emitted artifact. */ export class TypertEmitError extends Error { override name = 'TypertEmitError' @@ -317,6 +320,14 @@ export class FaceModelEmitter { lines.push(' },') }) lines.push(' ],') + if (invocation.uplink !== undefined) { + lines.push(' uplink: {') + lines.push(` codec: ${indent(strictCodec( + invocation.uplink.boundary, + schemas.boundary(uplinkBoundaryKey(invocation)), + ), 4).trimStart()},`) + lines.push(' },') + } if (invocation.cancellation !== undefined) { lines.push(" cancellation: { parameter: 'signal' },") } @@ -341,12 +352,18 @@ export class FaceModelEmitter { }) grouped.set(imported.specifier, values) } + // A stream method's Client signature names the handle from the protocol's + // Client entry; the Host entry's `RemoteStream` alias would shadow a + // same-named import inside the augmentation below. const lines = [ '/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */', 'import type {', ' RemoteResult,', ' TypertRemoteContribution,', '} from \'@deepseek-ai/dsh-typert-protocol\'', + ...packageModel.invocations.some(invocation => invocation.mode === 'stream') + ? [`import type { RemoteStreamHandle } from ${quote(REMOTE_CLIENT_PROTOCOL)}`] + : [], ] const sourceMap = new GenMapping({ file: 'typert.remote-client.d.ts' }) for (const [specifier, values] of [...grouped].sort(([left], [right]) => left.localeCompare(right))) { @@ -469,7 +486,10 @@ export class FaceModelEmitter { if (invocation.cancellation !== undefined) parameters.push('signal?: AbortSignal') const result = this.renderer.renderType(invocation.result.type, referenceNames) if (invocation.mode === 'stream') { - return `(${parameters.join(', ')}) => AsyncIterable<${result}>` + const uplink = invocation.uplink === undefined + ? 'never' + : this.renderer.renderType(invocation.uplink.boundary.type, referenceNames) + return `(${parameters.join(', ')}) => RemoteStreamHandle<${result}, ${uplink}>` } // The unary Client Remote face delivers the carrier's outcome, so every // generated consumer signature resolves to a result the caller reads. @@ -852,6 +872,9 @@ function invocationBoundaryRoots(invocations: readonly InvocationModel[]): Bound invocation.parameters.forEach((parameter, index) => { result.push({ key: parameterBoundaryKey(invocation, index), type: parameter.boundary.codecType }) }) + if (invocation.uplink !== undefined) { + result.push({ key: uplinkBoundaryKey(invocation), type: invocation.uplink.boundary.codecType }) + } result.push({ key: resultBoundaryKey(invocation), type: invocation.result.codecType }) } return result @@ -861,6 +884,10 @@ function contextBoundaryKey(invocation: InvocationModel): string { return `${invocation.id}:context` } +function uplinkBoundaryKey(invocation: InvocationModel): string { + return `${invocation.id}:uplink` +} + function parameterBoundaryKey(invocation: InvocationModel, index: number): string { return `${invocation.id}:parameter:${String(index)}` } @@ -894,6 +921,7 @@ function remoteImports(invocations: readonly InvocationModel[]): RemoteTypeImpor for (const invocation of invocations) { if (invocation.invocation.kind === 'context') add(invocation.invocation.boundary) for (const parameter of invocation.parameters) add(parameter.boundary) + if (invocation.uplink !== undefined) add(invocation.uplink.boundary) add(invocation.result) } return [...imports.values()].sort((left, right) => @@ -901,7 +929,7 @@ function remoteImports(invocations: readonly InvocationModel[]): RemoteTypeImpor } function allocateRemoteImportNames(imports: readonly RemoteTypeImportModel[]): ReadonlyMap { - const used = new Set(['TypertRemoteContribution', 'TYPERT_REMOTE']) + const used = new Set(['RemoteStreamHandle', 'TypertRemoteContribution', 'TYPERT_REMOTE']) const names = new Map() for (const imported of imports) { const base = safeIdentifier(imported.name) diff --git a/packages/typert/generator/src/model.ts b/packages/typert/generator/src/model.ts index 477c4368c4..ba88936e26 100644 --- a/packages/typert/generator/src/model.ts +++ b/packages/typert/generator/src/model.ts @@ -145,6 +145,10 @@ export interface InvocationModel { readonly wire: string } readonly parameters: readonly InvocationParameterModel[] + /** Per-item boundary of the Client uplink, from the `In` type argument of `RemoteStream`; absent when the method reads none. */ + readonly uplink?: { + readonly boundary: RemoteBoundaryModel + } readonly cancellation?: { readonly parameter: 'signal' } diff --git a/packages/typert/generator/tests/fixtures/remote-model/tsconfig.base.json b/packages/typert/generator/tests/fixtures/remote-model/tsconfig.base.json index 4561f6ef73..06c2be3ea8 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/tsconfig.base.json +++ b/packages/typert/generator/tests/fixtures/remote-model/tsconfig.base.json @@ -10,6 +10,7 @@ "ignoreDeprecations": "6.0", "paths": { "@deepseek-ai/dsh-typert-protocol": ["./typert-protocol.d.ts"], + "@deepseek-ai/dsh-typert-protocol/client": ["./typert-protocol.d.ts"], "@fixture/domain": ["./packages/domain/src/index.ts"], "@fixture/domain/*": ["./packages/domain/src/*"], "@fixture/remote": ["./packages/remote/src/index.ts"], diff --git a/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts b/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts index 37c741915c..38e1676c20 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts @@ -36,6 +36,8 @@ declare module '@deepseek-ai/dsh-typert-protocol' { readonly descriptors: readonly unknown[] } + export type RemoteStream = AsyncIterable & { readonly uplinkItem?: In } + export abstract class TypertRemoteService { readonly typertRemote: { readonly service: TypertRemoteService @@ -72,3 +74,21 @@ declare module '@deepseek-ai/dsh-typert-protocol' { context: ClassMethodDecoratorContext Result>, ) => void } + +declare module '@deepseek-ai/dsh-typert-protocol/client' { + export interface RemoteStreamHandle extends AsyncIterable { + send(item: In): void + end(): void + dispose(): void + } + + export type RemoteStream = RemoteStreamHandle + + export type { + RemoteResult, + TypertRemoteContribution, + TypertRemoteMap, + TypertRemoteNamespaceMap, + TypertRemoteScopeMap, + } from '@deepseek-ai/dsh-typert-protocol' +} diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index 635e12d334..8dfd773b08 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -27,6 +27,9 @@ interface RuntimeDescriptor { readonly acceptsUndefined?: true readonly codec: { readonly create: () => RuntimeSchema } }[] + readonly uplink?: { + readonly codec: { readonly create: () => RuntimeSchema } + } readonly result: { readonly create: () => RuntimeSchema } } @@ -142,7 +145,7 @@ describe('Remote model generation', { timeout: 60_000 }, () => { "'agent:goals/rename': (request: RenameGoalRequest) => Promise>", ) expect(artifact?.remote?.dts).toContain( - "'goals/watch': (agentId: AgentId, signal?: AbortSignal) => AsyncIterable", + "'goals/watch': (agentId: AgentId, signal?: AbortSignal) => RemoteStreamHandle", ) const remoteJs = artifact?.remote?.js @@ -219,6 +222,105 @@ export type {`, expect(labelled?.parameters[1]?.codec.create().safeParse(7).success).toBe(false) }) + it('models RemoteStream return types with an uplink boundary and renders them on consumers', async () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => source + .replace( + "import { TypertRemoteService, Remote, RemoteScope } from '@deepseek-ai/dsh-typert-protocol'", + "import { TypertRemoteService, Remote, RemoteScope, type RemoteStream } from '@deepseek-ai/dsh-typert-protocol'", + ) + .replace( + " @Remote({ mode: 'stream' })\n async *watch", + ` @Remote({ mode: 'stream' }) + async *attach(agent: Agent, signal: AbortSignal): RemoteStream { + signal.throwIfAborted() + yield { ref: agent.id } + } + + @Remote({ mode: 'stream' }) + async *tail(): RemoteStream { + yield 'tail' + } + + @Remote({ mode: 'stream' }) + async *silent(): RemoteStream { + yield 'silent' + } + + @Remote({ mode: 'stream' }) + async *watch`, + )) + + const model = remotePackage(root) + const attach = model.invocations.find(invocation => invocation.method === 'attach') + expect(attach).toMatchObject({ + id: '@fixture/remote#goals/attach', + mode: 'stream', + invocation: { kind: 'direct' }, + scope: { context: 'agent', wire: 'agentId' }, + parameters: [{ name: 'agent', wire: 'agentId', source: 'lookup', lookup: 'agent' }], + uplink: { boundary: { typeSymbol: '@fixture/remote/types#CreateGoalRequest' } }, + cancellation: { parameter: 'signal' }, + result: { typeSymbol: '@fixture/remote/types#CreateGoalResult' }, + }) + for (const method of ['tail', 'silent']) { + const modeled = model.invocations.find(invocation => invocation.method === method) + expect(modeled).toMatchObject({ mode: 'stream', parameters: [] }) + expect(modeled?.uplink).toBeUndefined() + expect(modeled?.cancellation).toBeUndefined() + } + + const [artifact] = new WorkspaceTypertGenerator(root).generate() + expect(artifact?.remote?.dts).toContain( + "import type { RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol/client'", + ) + expect(artifact?.remote?.dts).toContain("declare module '@deepseek-ai/dsh-typert-protocol' {") + expect(artifact?.remote?.dts).toContain( + "'goals/attach': (agentId: AgentId, signal?: AbortSignal) => RemoteStreamHandle", + ) + expect(artifact?.remote?.dts).toContain( + "'agent:goals/attach': (signal?: AbortSignal) => RemoteStreamHandle", + ) + expect(artifact?.remote?.dts).toContain("'goals/tail': () => RemoteStreamHandle") + expect(artifact?.remote?.dts).toContain("'goals/silent': () => RemoteStreamHandle") + + const remoteJs = artifact?.remote?.js + if (remoteJs === undefined) throw new Error('RemoteStream fixture emitted no Host-for-Client JavaScript') + const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`) + const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule + const descriptor = generated.TYPERT_REMOTE.descriptors.find(candidate => candidate.id.endsWith('/attach')) + expect(descriptor?.mode).toBe('stream') + expect(descriptor?.uplink?.codec.create().safeParse({ title: 'ship' }).success).toBe(true) + expect(descriptor?.uplink?.codec.create().safeParse({ title: 1 }).success).toBe(false) + expect(descriptor?.cancellation).toEqual({ parameter: 'signal' }) + expect(generated.TYPERT_REMOTE.descriptors.find(candidate => candidate.id.endsWith('/tail'))?.uplink).toBeUndefined() + assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root) + }) + + it.each([ + { + name: 'a stream method returning a Promise', + mode: 'stream', + method: 'async attach(agent: Agent, signal: AbortSignal): Promise', + message: 'stream Remote methods must return Iterable, AsyncIterable, or RemoteStream', + }, + { + name: 'an unknown Remote mode', + mode: 'duplex', + method: 'async *attach(agent: Agent, signal: AbortSignal): AsyncIterable', + message: 'Remote\\(\\) options must contain exactly mode: "stream"', + }, + ])('rejects $name', ({ mode, method, message }) => { + const root = copyFixture() + const decorator = mode === 'unary' ? '@Remote' : `@Remote({ mode: '${mode}' })` + editFile(root, 'packages/remote/src/index.ts', source => source.replace( + " @Remote({ mode: 'stream' })\n async *watch", + ` ${decorator}\n ${method} {\n throw new Error('fixture never runs')\n }\n\n @Remote({ mode: 'stream' })\n async *watch`, + )) + + expect(() => analyzeRemote(root, false)).toThrow(new RegExp(message)) + }) + it('evaluates declaration-merged mapped and conditional boundaries for codecs without widening consumer types', async () => { const root = copyFixture() editFile(root, 'packages/remote/src/types.ts', source => `${source} @@ -658,16 +760,19 @@ import type { TypertRemoteMap, TypertRemoteNamespaceMap, } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol/client' import type { CreateGoalResult, RenameGoalResult } from '@fixture/remote/types' const contribution: TypertRemoteContribution = remote declare const create: TypertRemoteMap['goals/create'] declare const createScoped: TypertRemoteScopeMap['agent:goals/create'] declare const rename: TypertRemoteScopeMap['agent:goals/rename'] +declare const watch: TypertRemoteMap['goals/watch'] const created: Promise> = create('agent-1', { title: 'ship' }) const cancellable: Promise> = create('agent-1', { title: 'ship' }, new AbortController().signal) const createdScoped: Promise> = createScoped({ title: 'ship' }) const renamed: Promise> = rename({ ref: 'goal-1', title: 'land' }) +const watched: RemoteStreamHandle = watch('agent-1') declare const ctx: { remote: TypertRemoteNamespaceMap } const navigated: Promise> = ctx.remote.goals.create('agent-1', { title: 'navigate' }) void contribution @@ -675,6 +780,7 @@ void created void cancellable void createdScoped void renamed +void watched void navigated ` writeFileSync(consumerPath, consumerSource) @@ -686,6 +792,7 @@ void navigated skipLibCheck: false, paths: { '@deepseek-ai/dsh-typert-protocol': ['./typert-protocol.d.ts'], + '@deepseek-ai/dsh-typert-protocol/client': ['./typert-protocol.d.ts'], '@fixture/domain/types': ['./packages/domain/src/types.ts'], '@fixture/remote/types': ['./packages/remote/src/types.ts'], '@fixture/remote/remote': ['./packages/remote/lib/typert.remote-client.d.ts'], diff --git a/packages/typert/protocol/package.json b/packages/typert/protocol/package.json index bd0f34482d..30a0570980 100644 --- a/packages/typert/protocol/package.json +++ b/packages/typert/protocol/package.json @@ -18,6 +18,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/types/client/index.js" + }, "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" @@ -31,6 +35,9 @@ "lib/types/**/*.d.ts" ], "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-brand": "workspace:^" + }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, diff --git a/packages/typert/protocol/src/client/index.ts b/packages/typert/protocol/src/client/index.ts new file mode 100644 index 0000000000..980f74b607 --- /dev/null +++ b/packages/typert/protocol/src/client/index.ts @@ -0,0 +1,45 @@ +/** + * Client face of the Typert protocol: every Host export, with `RemoteStream` + * resolving to the handle a generated Client method returns. Generated + * Host-for-Client contracts import `RemoteStreamHandle` from this entry for + * their stream signatures, so Client code that names a stream as + * `RemoteStream` from this entry and the generated method agree, + * while the Host alias stays an `AsyncIterable`. + * @module @deepseek-ai/dsh-typert-protocol/client + */ + +/** + * One open Remote stream as the Client holds it: the downlink items as an + * `AsyncIterable`, plus the uplink and cancellation of the same logical + * stream. A handle stands for one generation: when the carrier is lost, + * iteration fails with the carrier error and the handle is finished. + * @template Out - item type the Host method yields. + * @template In - item type the Client may send; `never` when the method reads none. + */ +export interface RemoteStreamHandle extends AsyncIterable { + /** + * Send one uplink item. Items sent before the stream has opened are queued + * and sent once the `open` frame is on the wire. + * @param item - item the Host validates against the method's uplink codec. + * @throws {Error} when `end()` was called or the stream has terminated. + */ + send(item: In): void + /** Half-close the uplink: the Host's `uplink()` iteration ends. Idempotent; ignored after termination. */ + end(): void + /** + * Cancel the logical stream: send `cancel` unless a terminal frame has + * arrived, and end the downlink iterator quietly. Breaking out of + * `for await` early does the same. + */ + dispose(): void +} + +/** + * `RemoteStream` on the Client face: the handle a generated Remote + * method returns. + * @template Out - item type the Host method yields. + * @template In - item type the Client may send; `never` when the method reads none. + */ +export type RemoteStream = RemoteStreamHandle + +export * from '../index.ts' diff --git a/packages/typert/protocol/src/index.ts b/packages/typert/protocol/src/index.ts index 7c3d902b19..9910ec2469 100644 --- a/packages/typert/protocol/src/index.ts +++ b/packages/typert/protocol/src/index.ts @@ -27,10 +27,14 @@ export type { InvocationDescriptor, InvocationParameterDescriptor, InvocationSourceLocation, + PeerId, + PeerScope, RemoteErrorCode, RemoteErrorDetailsMap, RemoteFailure, + RemoteInvocation, RemoteResult, + RemoteStream, TypertClientEventListener, TypertClientRemote, TypertClientContextAdapter, @@ -94,13 +98,13 @@ export interface RemoteMethodMarker { /** Endpoint method when it differs from the implementation member. */ readonly exportName?: string /** Stream methods yield many independently validated result items. */ - readonly mode?: 'stream' + readonly mode?: RemoteMethodOptions['mode'] readonly invocation: RemoteInvocationMarker } /** Options for a non-unary Remote method. */ export interface RemoteMethodOptions { - /** Deliver each Iterable item over the shared logical-stream carrier. */ + /** `stream`: deliver each Iterable item over the shared logical-stream carrier. */ readonly mode: 'stream' } @@ -118,7 +122,7 @@ interface RemoteInitializerContext { interface StoredRemoteMethodMarker { readonly exportName?: string - readonly mode?: 'stream' + readonly mode?: RemoteMethodOptions['mode'] readonly invocation: RemoteInvocationMarker } @@ -208,7 +212,7 @@ function remoteOptionMode(options: object): unknown { function remoteDecorator( invocation: RemoteInvocationMarker, - mode?: 'stream', + mode?: RemoteMethodOptions['mode'], exportName?: string, ): RemoteMethodDecorator { return function ( @@ -267,7 +271,7 @@ function readRemoteMethodDescriptor(prototype: object): RemoteMethodDescriptorV1 function addMarkerInitializer( context: RemoteInitializerContext, invocation: RemoteInvocationMarker, - mode?: 'stream', + mode?: RemoteMethodOptions['mode'], exportName?: string, ): void { if (context.private || context.static || typeof context.name !== 'string') { @@ -287,7 +291,7 @@ function mark( prototype: object, method: string, invocation: RemoteInvocationMarker, - mode?: 'stream', + mode?: RemoteMethodOptions['mode'], exportName?: string, ): void { const descriptor = readRemoteMethodDescriptor(prototype) diff --git a/packages/typert/protocol/src/types.ts b/packages/typert/protocol/src/types.ts index 358babac9d..4ec840592f 100644 --- a/packages/typert/protocol/src/types.ts +++ b/packages/typert/protocol/src/types.ts @@ -5,6 +5,7 @@ */ import type { Context, Events } from '@deepseek-ai/cordis' +import type { Branded } from '@deepseek-ai/dsh-brand' import type { TypertOwnedValue } from './owned-value.ts' declare const LOOKUP_HOST: unique symbol @@ -76,6 +77,22 @@ export type RemoteResult = | { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: RemoteFailure } +declare const STREAM_UPLINK: unique symbol + +/** + * One Remote stream as a Host method returns it: the items it yields to the + * Client, iterated as a plain `AsyncIterable`. `In` is the type of the + * items the Client may send back on the same logical stream, read through + * `RemoteInvocation.uplink()`; it is carried only as a type-level marker. The + * default `never` declares a method that reads none, and its descriptor + * carries no uplink codec. On the Client face the same alias, exported by + * `@deepseek-ai/dsh-typert-protocol/client`, is the stream handle a generated + * method returns as `RemoteStreamHandle`. + * @template Out - item type the Host method yields. + * @template In - item type the Client may send; `never` when the method reads none. + */ +export type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } + /** Merge-extensible scoped Remote method signatures generated for consumers. */ export interface TypertRemoteScopeMap {} @@ -286,6 +303,16 @@ export interface InvocationDescriptor { } /** Ordered business parameters. */ readonly parameters: readonly InvocationParameterDescriptor[] + /** + * Client-to-Host items of the same logical stream, generated from the `In` + * type argument of the method's `RemoteStream` return type; absent + * when `In` is `never`. The method reads the items through + * `RemoteInvocation.uplink()`, so nothing enters the parameter list. + */ + readonly uplink?: { + /** Codec validating every uplink item before `uplink()` delivers it. */ + readonly codec: TypertCodec + } /** Transport cancellation injected after business parameters instead of entering wire args. */ readonly cancellation?: { /** Reserved final Host method parameter. */ @@ -297,6 +324,60 @@ export interface InvocationDescriptor { readonly sourceLocation?: InvocationSourceLocation } +/** + * Opaque identity of one Peer: a party admitted to this Host by the connection + * layer. "Peer" is a connection-layer word; the browser application keeps the + * word "Client". + */ +export type PeerId = Branded<'PeerId'> + +/** + * One Peer's session on this Host. Opened and disposed by whoever admitted the + * Peer; `ctx` is the Cordis scope that owns connection-lifetime registrations. + * Who the Peer is and what it may do are not recorded here: business plugins + * attach that through `ctx` or a registry keyed by this object. + */ +export interface PeerScope { + readonly id: PeerId + readonly ctx: Context + /** + * Tear down every registration made through `ctx`. + * @returns settles once the scope has quiesced; racing calls share one completion. + */ + dispose(): Promise +} + +/** + * The context of one Remote call, reachable inside the receiving method as + * `this.ctx.invocation`. The Gateway derives the receiver from a Context that + * carries it, so no parameter is injected and nothing crosses the wire. + */ +export interface RemoteInvocation { + readonly request: { + readonly namespace: string + readonly method: string + readonly args: Readonly> + } + /** Cordis service key of the receiving Service. */ + readonly service: string + /** Peer the call speaks for; an in-process carrier speaks for the operator. */ + readonly peer: PeerScope + /** Carrier cancellation: Client cancel, socket close, or an uplink failure. */ + readonly signal: AbortSignal + /** + * The Client's uplink items for this call. Available once; a second call + * throws. With an uplink codec on the descriptor every item is decoded to + * `In`; without one items arrive as `unknown` after a JSON-safety check. + * Iteration ends when the Client ends its uplink; when the method finishes + * its downlink the Gateway calls the iterator's `return()` and unread items + * are dropped. `In` is the caller's assertion: the runtime decodes by the + * descriptor and does not cross-check it. + * @template In - item type the caller reads; the descriptor codec decides what arrives. + * @returns the single-consumer uplink iterable. + */ + uplink(): AsyncIterable +} + /** Generated Host contract selected explicitly by a Client assembly. */ export interface TypertRemoteContribution { /** npm package that owns the Remote methods. */ @@ -569,5 +650,11 @@ export interface TypertRegistryContract { declare module '@deepseek-ai/cordis' { interface Context { typert: TypertRegistryContract + /** + * The Remote call this Context was derived for, or `undefined` on a + * Context no Remote call derived. A Service method reads it as + * `this.ctx.invocation`. + */ + readonly invocation: RemoteInvocation | undefined } } diff --git a/packages/typert/protocol/tests/protocol.spec.ts b/packages/typert/protocol/tests/protocol.spec.ts index 09c85316e2..abc919bc82 100644 --- a/packages/typert/protocol/tests/protocol.spec.ts +++ b/packages/typert/protocol/tests/protocol.spec.ts @@ -240,6 +240,19 @@ describe('typert-protocol Remote declarations', () => { expect(remoteMethods(prototypeLess)).toEqual([]) }) + it('records the stream mode on the Remote marker', () => { + class Jobs { + follow(): void {} + } + const initializers: Array<(this: Jobs) => void> = [] + Remote({ mode: 'stream' })(Reflect.get(Jobs.prototype, 'follow'), methodContext('follow', initializers)) + const jobs = new Jobs() + for (const initialize of initializers) initialize.call(jobs) + expect(remoteMethods(jobs)).toEqual([ + { method: 'follow', mode: 'stream', invocation: { kind: 'direct' } }, + ]) + }) + it('rejects malformed decorator calls and targets', () => { const method: (this: object) => void = function (this: object): void {} expect(() => { (Remote as unknown as (value: typeof method) => void)(method) }).toThrow('context is missing') @@ -250,6 +263,9 @@ describe('typert-protocol Remote declarations', () => { expect(() => Remote('..')).toThrow('export name') expect(() => Remote({ mode: 'unary' } as unknown as { mode: 'stream' })).toThrow('exactly mode') expect(() => Remote({ mode: 'stream', extra: true } as unknown as { mode: 'stream' })).toThrow('exactly mode') + const bogusMode: string = 'duplex' + expect(() => Remote({ mode: bogusMode } as { mode: 'stream' })) + .toThrow('Remote options must contain exactly mode: "stream"') expect(() => RemoteScope('' as 'metaFixture')).toThrow('Scope key') expect(() => RemoteScope('metaFixture', 'bad/name')).toThrow('export name') diff --git a/packages/typert/protocol/tsconfig.json b/packages/typert/protocol/tsconfig.json index 754725418e..342f636170 100644 --- a/packages/typert/protocol/tsconfig.json +++ b/packages/typert/protocol/tsconfig.json @@ -13,6 +13,9 @@ }, { "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" } ] } diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 73c772fdd0..93a370e34f 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -692,6 +692,11 @@ function validateInvocation(descriptor: InvocationDescriptor): void { if (cancellation !== undefined && cancellation.parameter !== 'signal') { throw new Error(`typert: invocation "${descriptor.id}" cancellation parameter must be "signal"`) } + const mode = descriptor.mode as string | undefined + if (mode !== undefined && mode !== 'stream') { + throw new Error(`typert: invocation "${descriptor.id}" mode must be "stream"`) + } + if (descriptor.uplink !== undefined) validateCodec(descriptor.uplink.codec, `${descriptor.id} uplink`) if (descriptor.scope !== undefined) { if (descriptor.invocation.kind !== 'direct') { throw new Error(`typert: invocation "${descriptor.id}" Context receiver cannot declare a direct scope projection`) diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 936ed5c279..9f9b9c54cb 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -500,9 +500,25 @@ describe('TypertRegistry', () => { } const dispose = ctx.typert.remotes.register({ package: '@fixture/strict', descriptors: [strictInvocation] }) await dispose() + const uplinkInvocation: InvocationDescriptor = { + ...strictInvocation, + id: '@fixture/remote#attach', + method: 'attach', + mode: 'stream', + uplink: { codec: strict }, + } + const disposeUplink = ctx.typert.remotes.register({ package: '@fixture/uplink', descriptors: [uplinkInvocation] }) + await disposeUplink() + const bogusMode: string = 'duplex' const malformed: readonly [InvocationDescriptor, string][] = [ [{ ...invocation(), id: '' }, 'invocation id'], + [{ ...invocation(), mode: bogusMode as 'stream' }, 'mode must be "stream"'], + [{ + ...invocation(), + mode: 'stream', + uplink: { codec: { mode: 'strict', typeSymbol: '', create: () => z.string() } }, + }, 'uplink type symbol'], [{ ...invocation(), namespace: 'bad/name' }, 'namespace'], [{ ...invocation(), implementation: 'bad/name' }, 'implementation method'], [{ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae17bcec90..061dd9f5f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1285,6 +1285,9 @@ importers: '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../fs/fs + '@deepseek-ai/dsh-remote-mock': + specifier: workspace:^ + version: link:../../test-support/remote-mock '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -1380,6 +1383,9 @@ importers: '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../fs/fs + '@deepseek-ai/dsh-remote-mock': + specifier: workspace:^ + version: link:../../test-support/remote-mock '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:^ version: link:../../sandbox/sandbox-policy @@ -2371,6 +2377,12 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -11887,6 +11899,10 @@ importers: version: 4.4.3 packages/typert/protocol: + dependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index a013ed2097..48db9b5c32 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -200,7 +200,6 @@ "fc16f363fc891dac66e0be8c042da4b1d3f45c35af2d31a03f2214a6c3698379": 1 }, "packages/api/gateway/tests/stream-server.host.spec.ts": { - "53253a9a650abe4678a5eb411720231ef4a387ed2de78dde44054ff3d72efbbe": 1, "c7136804a6f7b829f525135643cb93cc8b3176b1d0036b2ebde5d7b668411658": 1, "dd244515f22ccbc14e5872126c3fc405ed6a63bb0390940976e234e3c06fcc2b": 1 }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 41d158f02a..7d154a1a5e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -82,6 +82,7 @@ "@deepseek-ai/dsh-api-workspace-controller/types": ["./packages/api/workspace-controller/src/types.ts"], "@deepseek-ai/dsh-typert-protocol": ["./packages/typert/protocol/src/index.ts"], "@deepseek-ai/dsh-typert-protocol/types": ["./packages/typert/protocol/src/types.ts"], + "@deepseek-ai/dsh-typert-protocol/client": ["./packages/typert/protocol/src/client/index.ts"], "@deepseek-ai/dsh-typert-loader": ["./packages/typert/loader/src/index.ts"], "@deepseek-ai/dsh-session/invariant": ["./packages/core/session/src/invariant.ts"], "@deepseek-ai/dsh-typert-registry/types": ["./packages/typert/registry/src/types.ts"], From 089004c418c3d7c7c3becd5faddfa0d17c2724b2 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 20 Sep 2026 11:29:08 +0800 Subject: [PATCH 18/60] test(gateway): cover the Remote uplink and document it Tests bring the changed sources to full per-file coverage, the READMEs and docs describe the uplink, the invocation context, and the Peer registry in both languages, and the generated catalogs are refreshed. --- docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 6 +- docs/api-gateway.zh.md | 6 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 8 +- docs/config-catalog.zh.md | 8 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/subsystems/typert.i18n.yaml | 4 +- docs/subsystems/typert.md | 99 ++++++++++++++++--- docs/subsystems/typert.zh.md | 99 ++++++++++++++++--- docs/subsystems/web-server.i18n.yaml | 4 +- docs/subsystems/web-server.md | 11 ++- docs/subsystems/web-server.zh.md | 11 ++- packages/api/README.i18n.yaml | 4 +- packages/api/README.md | 6 +- packages/api/README.zh.md | 6 +- packages/api/gateway/README.i18n.yaml | 4 +- packages/api/gateway/README.md | 10 +- packages/api/gateway/README.zh.md | 10 +- packages/api/gateway/src/client/index.ts | 4 +- packages/api/gateway/src/index.ts | 4 +- .../gateway/tests/gateway-stream.host.spec.ts | 28 ++++++ .../api/gateway/tests/gateway.client.spec.ts | 39 +++++++- .../gateway/tests/stream-server.host.spec.ts | 6 +- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 4 +- packages/client/connection/README.zh.md | 4 +- packages/client/connection/package.json | 9 +- .../connection/tests/node-half.host.spec.ts | 59 ++++++++++- .../extensions/tool-cordis/src/api-catalog.ts | 45 +++++++-- .../test-support/remote-mock/README.i18n.yaml | 4 +- packages/test-support/remote-mock/README.md | 6 +- .../test-support/remote-mock/README.zh.md | 6 +- .../remote-mock/tests/streams.client.spec.ts | 21 +++- packages/typert/generator/README.i18n.yaml | 4 +- packages/typert/generator/README.md | 2 +- packages/typert/generator/README.zh.md | 2 +- packages/typert/protocol/README.i18n.yaml | 4 +- packages/typert/protocol/README.md | 7 +- packages/typert/protocol/README.zh.md | 7 +- pnpm-lock.yaml | 12 +-- scripts/gen-cordis-catalog.ts | 4 + scripts/package-dependency-policy.ts | 2 +- scripts/type-equiv.manifest.json | 15 +++ 46 files changed, 494 insertions(+), 120 deletions(-) diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index c0bdc7fd9b..412d04555a 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.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/api-gateway.md -api-gateway.md: 8f2ca725f47dce089753168deefa7b3f28e4e52f -api-gateway.zh.md: 27e04d2d89f0d8c583d94bcf079980a5b0f8cb52 +api-gateway.md: ba67476f34e124bf6cd7e2874d0a200934644b93 +api-gateway.zh.md: cdc9443b37662428a1d1ba0730cb7b558b6ca2ab diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 8f2ca725f4..ba67476f34 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -2,7 +2,7 @@ English | [中文](api-gateway.zh.md) -This is the current-state reference for the Typert API Gateway. It describes how business services declare unary, stream, and duplex Remote methods, how the build generates Host and Client contracts, and how calls reuse the Connection RPC and `/api` route. Session events, incremental data, and other streaming protocols are outside this document's scope; they may use the same Connection but do not use Remote method descriptors. +This is the current-state reference for the Typert API Gateway. It describes how business services declare unary and stream Remote methods, how the build generates Host and Client contracts, and how calls reuse the Connection RPC and `/api` route. Session events, incremental data, and other streaming protocols are outside this document's scope; they may use the same Connection but do not use Remote method descriptors. ## Programming model @@ -55,7 +55,7 @@ export class GoalService extends TypertRemoteService { Remote methods may return a value synchronously or return a Promise. For cooperative cancellation, the final parameter in the Host signature must be `signal: AbortSignal` using the global type; it is recorded in the descriptor instead of entering `args`, while the generated Client method accepts an optional final `AbortSignal`. -`@Remote({ mode: 'stream' })` marks a method that returns `Iterable` or `AsyncIterable`: the Gateway validates and delivers each yielded item over its multiplexed `/api/remote.mux` WebSocket or an in-process carrier, and the generated Client method returns an `AsyncIterable`. `@Remote({ mode: 'duplex' })` adds the Client-to-Host direction: the Host signature declares the reserved `uplink: AsyncIterable` parameter immediately before the optional `signal`, the generated Client method accepts an `AsyncIterable` in the same position, and the Gateway validates each item the Client sends with the generated `In` codec before the Host method reads it. Like `signal`, `uplink` is recorded in the descriptor instead of entering `args`; the [Gateway README](../packages/api/gateway/README.md) owns the frame, half-close, cancellation, and inbox-bound contracts. +`@Remote({ mode: 'stream' })` marks a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream`: the Gateway validates and delivers each yielded item over its multiplexed `/api/remote.mux` WebSocket or an in-process carrier, and the generated Client method returns a stream handle — `RemoteStreamHandle`, the Client-face `RemoteStream` — that iterates the items and exposes `send`, `end`, and `dispose` for the Client-to-Host uplink of the same logical stream. The second type argument of `RemoteStream` declares the uplink item type; the Gateway validates each item the Client sends with the generated `In` codec before the Host method reads it through `this.ctx.invocation.uplink()`. The uplink enters neither `args` nor the parameter list; the [Gateway README](../packages/api/gateway/README.md) owns the frame, half-close, cancellation, and inbox-bound contracts. The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. Direct and scoped calls appear under `ctx.remote.` and `agentCtx.remote.`. Each namespace is a traced Cordis child Service registered as `remote.`; the Client assembly mounts contributions through `ctx.remote.$mount()`, and the namespace unloads after its last method is withdrawn. Dependency declarations belong to the actual caller: only a business package that reads `ctx.remote.` or `agentCtx.remote.` declares both `remote` and `remote.` in its own `inject`; assemblies that only mount contributions and higher-level runtimes that do not call that namespace do not declare the namespace dependency on the business package's behalf. When an `@Remote` method has exactly one lookup parameter and a same-named `TypertContextMap` uses the same wire identity, the generated scoped signature omits that identity parameter. `@RemoteScope` generates only the scoped invocation interface. @@ -158,7 +158,7 @@ The running Client watcher consumes these generated files when it rebundles. If ## Boundaries -Remote handles unary method calls with one request and one result, stream methods whose items flow Host → Client, and duplex methods whose Client items reach the running Host method through the reserved `uplink` parameter. Session event streams, pagination, incremental reduce, projection, and entity substreams still require a separate data protocol and registration model; even when they reuse the Connection, they must not masquerade as Remote methods or enter invocation descriptors. +Remote handles unary method calls with one request and one result, and stream methods whose items flow Host → Client while the Client's items reach the running Host method through `this.ctx.invocation.uplink()`. Session event streams, pagination, incremental reduce, projection, and entity substreams still require a separate data protocol and registration model; even when they reuse the Connection, they must not masquerade as Remote methods or enter invocation descriptors. The API layers are organized as `remotes → gateway → connection → webserver`. The BFF and Typert RPC layers live under `packages/api`; Connection and WebServer live at `packages/client/connection` and `packages/host/webserver`. A feature that needs a streamed or browser-native response registers an exact Connection Fetch route instead of defining a Remote method. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 27e04d2d89..cdc9443b37 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -2,7 +2,7 @@ [English](api-gateway.md) | 中文 -本文是 Typert API Gateway 的当前状态参考。它描述业务服务如何声明一元、流式与双工 Remote 方法、构建如何生成 Host 与 Client 约定,以及调用如何复用 Connection 的 RPC 与 `/api` 路由。会话事件、增量数据和其他流协议不属于本文范围;它们可以使用同一个 Connection,但不使用 Remote 方法描述符。 +本文是 Typert API Gateway 的当前状态参考。它描述业务服务如何声明一元与流式 Remote 方法、构建如何生成 Host 与 Client 约定,以及调用如何复用 Connection 的 RPC 与 `/api` 路由。会话事件、增量数据和其他流协议不属于本文范围;它们可以使用同一个 Connection,但不使用 Remote 方法描述符。 ## 编程模型 @@ -55,7 +55,7 @@ export class GoalService extends TypertRemoteService { Remote 方法可以同步返回或返回 Promise。若需要协作式取消,Host 签名的最后一个参数必须是全局类型的 `signal: AbortSignal`;它记录在描述符中而不是进入 `args`,Client 生成的方法则接受最后一个可选的 `AbortSignal`。 -`@Remote({ mode: 'stream' })` 标记返回 `Iterable` 或 `AsyncIterable` 的方法:Gateway 校验每个产出的项,并经其多路复用的 `/api/remote.mux` WebSocket 或进程内载体投递,Client 生成的方法返回 `AsyncIterable`。`@Remote({ mode: 'duplex' })` 增加 Client 到 Host 的方向:Host 签名在可选的 `signal` 之前紧邻声明保留参数 `uplink: AsyncIterable`,Client 生成的方法在同一位置接受 `AsyncIterable`,Gateway 在 Host 方法读取之前用生成的 `In` codec 校验 Client 发送的每一项。与 `signal` 相同,`uplink` 记录在描述符中而不进入 `args`;帧、半关闭、取消与 inbox 上限的约定由 [Gateway README](../packages/api/gateway/README.zh.md) 拥有。 +`@Remote({ mode: 'stream' })` 标记返回 `Iterable`、`AsyncIterable` 或 `RemoteStream` 的方法:Gateway 校验每个产出的项,并经其多路复用的 `/api/remote.mux` WebSocket 或进程内载体投递,Client 生成的方法返回一个流句柄——`RemoteStreamHandle`,即 Client 面的 `RemoteStream`——它迭代这些项,并为同一条逻辑流上从 Client 到 Host 的上行提供 `send`、`end` 与 `dispose`。`RemoteStream` 的第二个类型参数声明上行项类型;Gateway 在 Host 方法通过 `this.ctx.invocation.uplink()` 读取之前,用生成的 `In` codec 校验 Client 发送的每一项。上行既不进入 `args` 也不进入参数列表;帧、半关闭、取消与 inbox 上限的约定由 [Gateway README](../packages/api/gateway/README.zh.md) 拥有。 Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接调用与作用域调用分别出现在 `ctx.remote.` 和 `agentCtx.remote.`。每个 namespace 都是注册为 `remote.` 的可追踪 Cordis 子服务;Client assembly 通过 `ctx.remote.$mount()` 挂载贡献,最后一个方法撤回后该 namespace 随即卸载。依赖声明归实际调用方所有:只有读取 `ctx.remote.` 或 `agentCtx.remote.` 的业务包才在自己的 `inject` 中同时声明 `remote` 与 `remote.`;只负责挂载 contribution 的 assembly,以及不调用该 namespace 的上层运行时,不代业务包声明 namespace 依赖。当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypertContextMap` 使用相同 wire identity 时,生成的作用域签名会省略该 identity 参数。`@RemoteScope` 只生成作用域调用接口。 @@ -158,7 +158,7 @@ pnpm run build:lib ## 边界 -Remote 处理单请求单结果的一元方法调用、项沿 Host → Client 方向流动的流方法,以及 Client 的项经保留参数 `uplink` 到达正在运行的 Host 方法的双工方法。会话事件流、分页、增量 reduce、projection 和实体子流仍需要独立的数据协议与注册模型;即使它们复用 Connection,也不应伪装成 Remote 方法或放入调用描述符。 +Remote 处理单请求单结果的一元方法调用,以及项沿 Host → Client 方向流动、而 Client 的项经 `this.ctx.invocation.uplink()` 到达正在运行的 Host 方法的流方法。会话事件流、分页、增量 reduce、projection 和实体子流仍需要独立的数据协议与注册模型;即使它们复用 Connection,也不应伪装成 Remote 方法或放入调用描述符。 API 各层按 `remotes → gateway → connection → webserver` 组织。BFF 与 Typert RPC 层位于 `packages/api`;Connection 与 WebServer 位于 `packages/client/connection` 和 `packages/host/webserver`。需要流式或浏览器原生响应的功能注册精确的 Connection Fetch 路由,而不定义 Remote 方法。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 4f95ff65f0..cde8b8f8f4 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: a740e866ec458ab116cdf10cf5733497886a8ba8 -config-catalog.zh.md: d21f4d5701b12a39b5a620c337f5ab93b93bbb5a +config-catalog.md: f8ccc54835146648f8c090e274bfa27af88471e8 +config-catalog.zh.md: 76e5c6f81fa6bebad3502530a921d3a0d224a0f6 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a740e866ec..f8ccc54835 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -194,12 +194,12 @@ Requires: `typert` export interface Config { /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 2000 */ readonly websocketHeartbeatIntervalMs?: number - /** Buffered uplink frame bytes one duplex logical stream may hold before it fails with `gateway/uplink-overflow`. @default 262144 */ - readonly duplexInboxBytes?: number + /** Buffered uplink frame bytes one logical stream may hold before it fails with `gateway/uplink-overflow`. @default 262144 */ + readonly streamInboxBytes?: number } ``` -Source: [`packages/api/gateway/src/index.ts:128`](../packages/api/gateway/src/index.ts) +Source: [`packages/api/gateway/src/index.ts:141`](../packages/api/gateway/src/index.ts) @@ -430,7 +430,7 @@ export interface ConnectionRecoveryConfig { } ``` -Source: [`packages/client/connection/src/index.ts:87`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:91`](../packages/client/connection/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index d21f4d5701..76e5c6f81f 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -196,12 +196,12 @@ Depends on: [`ToolPresentationMode`](subsystems/tools.zh.md) export interface Config { /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 2000 */ readonly websocketHeartbeatIntervalMs?: number - /** Buffered uplink frame bytes one duplex logical stream may hold before it fails with `gateway/uplink-overflow`. @default 262144 */ - readonly duplexInboxBytes?: number + /** Buffered uplink frame bytes one logical stream may hold before it fails with `gateway/uplink-overflow`. @default 262144 */ + readonly streamInboxBytes?: number } ``` -来源:[`packages/api/gateway/src/index.ts:128`](../packages/api/gateway/src/index.ts) +来源:[`packages/api/gateway/src/index.ts:141`](../packages/api/gateway/src/index.ts) @@ -432,7 +432,7 @@ export interface ConnectionRecoveryConfig { } ``` -来源: [`packages/client/connection/src/index.ts:87`](../packages/client/connection/src/index.ts) +来源: [`packages/client/connection/src/index.ts:91`](../packages/client/connection/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index f4e736efa4..9bd87adc80 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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/event-producer-consumer.md -event-producer-consumer.md: 0fa3e7ffaae73a714bcffc6b7f8cd69e24f5e0c4 -event-producer-consumer.zh.md: 9f14a99ad80959be9963821100a6228700eca2b8 +event-producer-consumer.md: a0bf6543c2d97c68d85eaafd94f0049660ab34a9 +event-producer-consumer.zh.md: bde5a953db35254c30254efe0f5f5209fc12870b diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 0fa3e7ffaa..a0bf6543c2 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -30,7 +30,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | | `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:106`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | -| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:62`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | +| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:66`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 9f14a99ad8..bde5a953db 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -32,7 +32,7 @@ | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | | `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:106`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | -| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:62`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | +| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:66`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index 9b313eb36f..1cf84e38da 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/typert.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/typert.md -typert.md: 6d398fbb29a679a3830c9913c93bb4c8d7513f7d -typert.zh.md: 738ac31d854264ebdd1994df8e162e469bd3884d +typert.md: 88e69f4ae264cc720efbfb8154f13a84d39160f8 +typert.zh.md: 306c1f4f46f3cb9b2816bbfcbdca5b8bc9fc2b76 diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index 6d398fbb29..88e69f4ae2 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -85,8 +85,8 @@ interface InvocationDescriptor { readonly method: string /** Service member invoked when the exported method name is an alias. */ readonly implementation?: string - /** Absent for unary calls; stream and duplex calls validate and deliver every yielded item. */ - readonly mode?: 'stream' | 'duplex' + /** Absent for unary calls; stream calls validate and deliver every yielded item. */ + readonly mode?: 'stream' /** Receiver selection mode. */ readonly invocation: | { readonly kind: 'direct' } @@ -105,11 +105,14 @@ interface InvocationDescriptor { } /** Ordered business parameters. */ readonly parameters: readonly InvocationParameterDescriptor[] - /** Duplex only: Client-to-Host item stream injected before `signal` instead of entering wire args. */ + /** + * Client-to-Host items of the same logical stream, generated from the `In` + * type argument of the method's `RemoteStream` return type; absent + * when `In` is `never`. The method reads the items through + * `RemoteInvocation.uplink()`, so nothing enters the parameter list. + */ readonly uplink?: { - /** Reserved Host method parameter preceding the optional `signal`. */ - readonly parameter: 'uplink' - /** Codec validating every uplink item before it reaches the Host method. */ + /** Codec validating every uplink item before `uplink()` delivers it. */ readonly codec: TypertCodec } /** Transport cancellation injected after business parameters instead of entering wire args. */ @@ -124,6 +127,75 @@ interface InvocationDescriptor { } ``` +The Host alias `RemoteStream` names both directions of one stream, and the receiving method reads its call context as `this.ctx.invocation`: + +```ts type-equiv +/** + * One Remote stream as a Host method returns it: the items it yields to the + * Client, iterated as a plain `AsyncIterable`. `In` is the type of the + * items the Client may send back on the same logical stream, read through + * `RemoteInvocation.uplink()`; it is carried only as a type-level marker. The + * default `never` declares a method that reads none, and its descriptor + * carries no uplink codec. On the Client face the same alias, exported by + * `@deepseek-ai/dsh-typert-protocol/client`, is the stream handle a generated + * method returns as `RemoteStreamHandle`. + * @template Out - item type the Host method yields. + * @template In - item type the Client may send; `never` when the method reads none. + */ +type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } +``` + +```ts type-equiv +/** + * One Peer's session on this Host. Opened and disposed by whoever admitted the + * Peer; `ctx` is the Cordis scope that owns connection-lifetime registrations. + * Who the Peer is and what it may do are not recorded here: business plugins + * attach that through `ctx` or a registry keyed by this object. + */ +interface PeerScope { + readonly id: PeerId + readonly ctx: Context + /** + * Tear down every registration made through `ctx`. + * @returns settles once the scope has quiesced; racing calls share one completion. + */ + dispose(): Promise +} +``` + +```ts type-equiv +/** + * The context of one Remote call, reachable inside the receiving method as + * `this.ctx.invocation`. The Gateway derives the receiver from a Context that + * carries it, so no parameter is injected and nothing crosses the wire. + */ +interface RemoteInvocation { + readonly request: { + readonly namespace: string + readonly method: string + readonly args: Readonly> + } + /** Cordis service key of the receiving Service. */ + readonly service: string + /** Peer the call speaks for; an in-process carrier speaks for the operator. */ + readonly peer: PeerScope + /** Carrier cancellation: Client cancel, socket close, or an uplink failure. */ + readonly signal: AbortSignal + /** + * The Client's uplink items for this call. Available once; a second call + * throws. With an uplink codec on the descriptor every item is decoded to + * `In`; without one items arrive as `unknown` after a JSON-safety check. + * Iteration ends when the Client ends its uplink; when the method finishes + * its downlink the Gateway calls the iterator's `return()` and unread items + * are dropped. `In` is the caller's assertion: the runtime decodes by the + * descriptor and does not cross-check it. + * @template In - item type the caller reads; the descriptor codec decides what arrives. + * @returns the single-consumer uplink iterable. + */ + uplink(): AsyncIterable +} +``` + ## Typert registry `ctx.typert` separates current-environment descriptors, explicitly selected Remote contributions, lookup providers, and scoped Context providers. A lookup provider owns the stable wire declaration and default resolver; Host composition can configure an effect-scoped synchronous or asynchronous resolver for the same key, and unloading that configuration restores the default policy. Registrations are Cordis-owned effects and return awaitable disposers. @@ -158,8 +230,13 @@ interface InvokeRemoteRequest { readonly method: string /** Named wire values; fields must exactly match the descriptor. */ readonly args: Readonly> - /** Duplex uplink items decoded per item before reaching the method; absent means an immediately ended iterable. */ + /** + * Client uplink items of this logical stream, delivered to the method through + * `invocation.uplink()`; absent means an immediately ended iterable. + */ readonly uplink?: AsyncIterable + /** Peer the call speaks for; absent means an in-process carrier, answered as the operator. */ + readonly peer?: PeerScope /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ readonly signal?: AbortSignal } @@ -212,8 +289,8 @@ interface TypertGateway { */ invoke(request: InvokeRemoteRequest): Promise /** - * Open one live stream or duplex Remote method without assuming a physical carrier. - * @param request - decoded endpoint, named wire arguments, and the duplex uplink when the method reads one. + * Open one live stream Remote method without assuming a physical carrier. + * @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one. * @returns a cancellation-aware iterable over the business results. */ stream(request: InvokeRemoteRequest): Promise> @@ -344,8 +421,8 @@ registerRemoteEvents( source: TypertRemoteEventSource, host: RemoteEventHostInfo async invoke(request: InvokeRemoteRequest): Promise /** - * Open one live stream or duplex Remote method without assuming a physical carrier. - * @param request - decoded endpoint, named wire arguments, and the duplex uplink when the method reads one. + * Open one live stream Remote method without assuming a physical carrier. + * @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one. * @returns a cancellation-aware iterable over the business results. */ async stream(request: InvokeRemoteRequest): Promise> diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index 738ac31d85..306c1f4f46 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -85,8 +85,8 @@ interface InvocationDescriptor { readonly method: string /** Service member invoked when the exported method name is an alias. */ readonly implementation?: string - /** Absent for unary calls; stream and duplex calls validate and deliver every yielded item. */ - readonly mode?: 'stream' | 'duplex' + /** Absent for unary calls; stream calls validate and deliver every yielded item. */ + readonly mode?: 'stream' /** Receiver selection mode. */ readonly invocation: | { readonly kind: 'direct' } @@ -105,11 +105,14 @@ interface InvocationDescriptor { } /** Ordered business parameters. */ readonly parameters: readonly InvocationParameterDescriptor[] - /** Duplex only: Client-to-Host item stream injected before `signal` instead of entering wire args. */ + /** + * Client-to-Host items of the same logical stream, generated from the `In` + * type argument of the method's `RemoteStream` return type; absent + * when `In` is `never`. The method reads the items through + * `RemoteInvocation.uplink()`, so nothing enters the parameter list. + */ readonly uplink?: { - /** Reserved Host method parameter preceding the optional `signal`. */ - readonly parameter: 'uplink' - /** Codec validating every uplink item before it reaches the Host method. */ + /** Codec validating every uplink item before `uplink()` delivers it. */ readonly codec: TypertCodec } /** Transport cancellation injected after business parameters instead of entering wire args. */ @@ -124,6 +127,75 @@ interface InvocationDescriptor { } ``` +Host 别名 `RemoteStream` 同时命名一条流的两个方向,接收方法通过 `this.ctx.invocation` 读取本次调用的上下文: + +```ts type-equiv +/** + * One Remote stream as a Host method returns it: the items it yields to the + * Client, iterated as a plain `AsyncIterable`. `In` is the type of the + * items the Client may send back on the same logical stream, read through + * `RemoteInvocation.uplink()`; it is carried only as a type-level marker. The + * default `never` declares a method that reads none, and its descriptor + * carries no uplink codec. On the Client face the same alias, exported by + * `@deepseek-ai/dsh-typert-protocol/client`, is the stream handle a generated + * method returns as `RemoteStreamHandle`. + * @template Out - item type the Host method yields. + * @template In - item type the Client may send; `never` when the method reads none. + */ +type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } +``` + +```ts type-equiv +/** + * One Peer's session on this Host. Opened and disposed by whoever admitted the + * Peer; `ctx` is the Cordis scope that owns connection-lifetime registrations. + * Who the Peer is and what it may do are not recorded here: business plugins + * attach that through `ctx` or a registry keyed by this object. + */ +interface PeerScope { + readonly id: PeerId + readonly ctx: Context + /** + * Tear down every registration made through `ctx`. + * @returns settles once the scope has quiesced; racing calls share one completion. + */ + dispose(): Promise +} +``` + +```ts type-equiv +/** + * The context of one Remote call, reachable inside the receiving method as + * `this.ctx.invocation`. The Gateway derives the receiver from a Context that + * carries it, so no parameter is injected and nothing crosses the wire. + */ +interface RemoteInvocation { + readonly request: { + readonly namespace: string + readonly method: string + readonly args: Readonly> + } + /** Cordis service key of the receiving Service. */ + readonly service: string + /** Peer the call speaks for; an in-process carrier speaks for the operator. */ + readonly peer: PeerScope + /** Carrier cancellation: Client cancel, socket close, or an uplink failure. */ + readonly signal: AbortSignal + /** + * The Client's uplink items for this call. Available once; a second call + * throws. With an uplink codec on the descriptor every item is decoded to + * `In`; without one items arrive as `unknown` after a JSON-safety check. + * Iteration ends when the Client ends its uplink; when the method finishes + * its downlink the Gateway calls the iterator's `return()` and unread items + * are dropped. `In` is the caller's assertion: the runtime decodes by the + * descriptor and does not cross-check it. + * @template In - item type the caller reads; the descriptor codec decides what arrives. + * @returns the single-consumer uplink iterable. + */ + uplink(): AsyncIterable +} +``` + ## Typert 注册表 `ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、lookup 提供方与作用域上下文提供方。lookup 提供方拥有稳定 wire 声明和默认 resolver;Host 组合可以为同一个 key 配置 effect-scoped 同步或异步 resolver,配置卸载后恢复默认策略。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。 @@ -158,8 +230,13 @@ interface InvokeRemoteRequest { readonly method: string /** Named wire values; fields must exactly match the descriptor. */ readonly args: Readonly> - /** Duplex uplink items decoded per item before reaching the method; absent means an immediately ended iterable. */ + /** + * Client uplink items of this logical stream, delivered to the method through + * `invocation.uplink()`; absent means an immediately ended iterable. + */ readonly uplink?: AsyncIterable + /** Peer the call speaks for; absent means an in-process carrier, answered as the operator. */ + readonly peer?: PeerScope /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ readonly signal?: AbortSignal } @@ -212,8 +289,8 @@ interface TypertGateway { */ invoke(request: InvokeRemoteRequest): Promise /** - * Open one live stream or duplex Remote method without assuming a physical carrier. - * @param request - decoded endpoint, named wire arguments, and the duplex uplink when the method reads one. + * Open one live stream Remote method without assuming a physical carrier. + * @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one. * @returns a cancellation-aware iterable over the business results. */ stream(request: InvokeRemoteRequest): Promise> @@ -344,8 +421,8 @@ registerRemoteEvents( source: TypertRemoteEventSource, host: RemoteEventHostInfo async invoke(request: InvokeRemoteRequest): Promise /** - * Open one live stream or duplex Remote method without assuming a physical carrier. - * @param request - decoded endpoint, named wire arguments, and the duplex uplink when the method reads one. + * Open one live stream Remote method without assuming a physical carrier. + * @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one. * @returns a cancellation-aware iterable over the business results. */ async stream(request: InvokeRemoteRequest): Promise> diff --git a/docs/subsystems/web-server.i18n.yaml b/docs/subsystems/web-server.i18n.yaml index 558b700088..7c43d065a5 100644 --- a/docs/subsystems/web-server.i18n.yaml +++ b/docs/subsystems/web-server.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/web-server.md -web-server.md: e74d51da21682c0271c42b0a4584558e41b52b26 -web-server.zh.md: da89262e7055dbf4d6ee044def4cb5a08ce4dfe4 +web-server.md: 83c4793d2067ab6101f51ecb94b29ef477bea3ca +web-server.zh.md: 8a22f142ef0d851b24519695928c670a23383f5b diff --git a/docs/subsystems/web-server.md b/docs/subsystems/web-server.md index e74d51da21..83c4793d20 100644 --- a/docs/subsystems/web-server.md +++ b/docs/subsystems/web-server.md @@ -64,7 +64,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.connection` — `HostConnectionHandle` -Host `ctx.connection` shape consumed by transport-independent adapters. +Host `ctx.connection` members consumed by transport-independent adapters. ```ts cordis-catalog /** @@ -82,6 +82,15 @@ createSharedFetchHandler(channel: '/api'): ConnectionFetchHandler */ requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection +/** + * Decide which Peer one request speaks for: a carrier bound through `peers` + * answers from its binding; every other request passes {@link requestRejection} + * and speaks for the operator. + * @param request - request headers from the HTTP or upgrade request. + * @returns the admitted Peer, or the rejection status. + */ +admit(request: ConnectionTrustRequest): PeerAdmission + /** * Authenticate one frontend index request, owning a token redirect or 401. * @param request - root or configured-index HTTP request. diff --git a/docs/subsystems/web-server.zh.md b/docs/subsystems/web-server.zh.md index da89262e70..8a22f142ef 100644 --- a/docs/subsystems/web-server.zh.md +++ b/docs/subsystems/web-server.zh.md @@ -64,7 +64,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.connection` — `HostConnectionHandle` -Host `ctx.connection` shape consumed by transport-independent adapters. +Host `ctx.connection` members consumed by transport-independent adapters. ```ts cordis-catalog /** @@ -82,6 +82,15 @@ createSharedFetchHandler(channel: '/api'): ConnectionFetchHandler */ requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection +/** + * Decide which Peer one request speaks for: a carrier bound through `peers` + * answers from its binding; every other request passes {@link requestRejection} + * and speaks for the operator. + * @param request - request headers from the HTTP or upgrade request. + * @returns the admitted Peer, or the rejection status. + */ +admit(request: ConnectionTrustRequest): PeerAdmission + /** * Authenticate one frontend index request, owning a token redirect or 401. * @param request - root or configured-index HTTP request. diff --git a/packages/api/README.i18n.yaml b/packages/api/README.i18n.yaml index 7c218129c3..0de9905f67 100644 --- a/packages/api/README.i18n.yaml +++ b/packages/api/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/api/README.md -README.md: 94e7ae897d137a36a365a9c45b6ef9fa6617a624 -README.zh.md: 4473ee9de0a8634a9dbc9b485a366457b0ca6b18 +README.md: 79271f6f5f7a05ef4f6d5da63815dfe1ee5da3ef +README.zh.md: d98a0ef73a1b14a5eea98084cfb4ef224a7747a9 diff --git a/packages/api/README.md b/packages/api/README.md index 94e7ae897d..79271f6f5f 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The `api/` group provides the application's Remote layer: a Client environment can call the business capabilities running on the Host — manage goals, run commands, list the plugin inventory, discover file and session references — as typed method calls, receive results or streams, send duplex uplinks, and observe forwarded Host events. `remotes` decides which capabilities are exposed and how each call reaches the right session's agent; `gateway` carries the calls and their results between Client and Host. The stack runs over the application's shared Connection; streaming session data is deliberately outside it. +The `api/` group provides the application's Remote layer: a Client environment can call the business capabilities running on the Host — manage goals, run commands, list the plugin inventory, discover file and session references — as typed method calls, receive results or streams, send uplink items on an open stream, and observe forwarded Host events. `remotes` decides which capabilities are exposed and how each call reaches the right session's agent; `gateway` carries the calls and their results between Client and Host. The stack runs over the application's shared Connection; streaming session data is deliberately outside it. ## Table of Contents @@ -27,14 +27,14 @@ The packages below provide the Remote layer; the package READMEs own the exhaust | Package | Role | ctx key | |---|---|---| | [`remotes/`](remotes/README.md) | Chooses which Host capabilities and events the Client can consume. | — | -| [`gateway/`](gateway/README.md) | Carries typed unary calls, multiplexed streams with optional duplex uplinks, and forwarded Host events. | `ctx.typertGateway` / `ctx.remote` | +| [`gateway/`](gateway/README.md) | Carries typed unary calls, multiplexed streams with their Client uplinks, and forwarded Host events. | `ctx.typertGateway` / `ctx.remote` | | [`session-controller/`](session-controller/README.md) | Owns Session commands, history streams, live control state, and Agent/Session identity policy. | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.md) | Owns the configuration-surface reads and writes over the settings-domain seams. | `ctx.settingsController`, `ctx.credentialsController` / `ctx.remote.settings`, `ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.md) | Owns Workspace mutations and the complete Client Workspace projection. | `ctx.workspaceController` / `ctx.remote.workspace` | | [`terminal-controller/`](terminal-controller/README.md) | Session-owned interactive shells, screen recovery and browser terminal control. | `ctx.terminalController` / `ctx.remote.terminal` | | [`workspace-files/`](workspace-files/README.md) | Owns bounded workspace file access — `stat`, paged `read`, `list`, and the instrumented-operation `changes` feed — and the Client `file` resource provider over it. | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` | -Remote calls run Client → Host over the application's shared Connection; stream items flow Host → Client on the Gateway mux, and a duplex method's uplink items flow Client → Host on the same logical stream. API Gateway owns Remote transport, while the controller packages own Session, configuration-surface, and Workspace behavior. Feature packages register exact Connection Fetch routes for responses that do not fit Remote invocation, such as streamed downloads. +Remote calls run Client → Host over the application's shared Connection; stream items flow Host → Client on the Gateway mux, and a stream's uplink items flow Client → Host on the same logical stream. API Gateway owns Remote transport, while the controller packages own Session, configuration-surface, and Workspace behavior. Feature packages register exact Connection Fetch routes for responses that do not fit Remote invocation, such as streamed downloads. ----- diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md index 4473ee9de0..d98a0ef73a 100644 --- a/packages/api/README.zh.md +++ b/packages/api/README.zh.md @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -`api/` 组提供应用的 Remote 层:Client 环境可以调用运行在 Host 上的业务能力——管理目标、运行命令、查看插件清单、发现文件与会话引用——调用方式是类型化方法,可接收结果或流、发送双工上行,并观察转发的 Host 事件。`remotes` 决定暴露哪些能力、以及每次调用如何到达正确会话的 agent;`gateway` 在 Client 与 Host 之间承载调用及其结果。技术栈运行在应用共享的 Connection 之上;流式会话数据刻意不在其中。 +`api/` 组提供应用的 Remote 层:Client 环境可以调用运行在 Host 上的业务能力——管理目标、运行命令、查看插件清单、发现文件与会话引用——调用方式是类型化方法,可接收结果或流、在已打开的流上发送上行项,并观察转发的 Host 事件。`remotes` 决定暴露哪些能力、以及每次调用如何到达正确会话的 agent;`gateway` 在 Client 与 Host 之间承载调用及其结果。技术栈运行在应用共享的 Connection 之上;流式会话数据刻意不在其中。 ## 目录 @@ -27,14 +27,14 @@ kind: "package-group" | 包 | 职责 | ctx key | |---|---|---| | [`remotes/`](remotes/README.zh.md) | 决定 Client 可以消费哪些 Host 能力与事件。 | — | -| [`gateway/`](gateway/README.zh.md) | 承载类型化一元调用、可带双工上行的多路复用流,以及转发的 Host 事件。 | `ctx.typertGateway` / `ctx.remote` | +| [`gateway/`](gateway/README.zh.md) | 承载类型化一元调用、带 Client 上行的多路复用流,以及转发的 Host 事件。 | `ctx.typertGateway` / `ctx.remote` | | [`session-controller/`](session-controller/README.zh.md) | 拥有会话命令、历史记录流、实时控制状态与 Agent/Session 身份策略。 | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.zh.md) | 拥有 settings 域各 seam 之上的配置界面读写。 | `ctx.settingsController`、`ctx.credentialsController` / `ctx.remote.settings`、`ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.zh.md) | 拥有 Workspace 变更与完整 Client Workspace 投影。 | `ctx.workspaceController` / `ctx.remote.workspace` | | [`terminal-controller/`](terminal-controller/README.zh.md) | Session 拥有的交互式 shell、屏幕恢复和浏览器终端控制。 | `ctx.terminalController` / `ctx.remote.terminal` | | [`workspace-files/`](workspace-files/README.zh.md) | 拥有有界的工作区文件访问——`stat`、分页 `read`、`list` 与已埋点操作的 `changes` 流——以及其上的 Client `file` 资源提供方。 | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` | -Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上;流项沿 Host → Client 方向经 Gateway mux 传输,双工方法的上行项则沿 Client → Host 方向走同一条逻辑流。API Gateway 拥有 Remote 传输,各控制器包分别拥有 Session、配置界面与 Workspace 行为。流式下载等不适合 Remote 调用的响应由功能包注册精确的 Connection Fetch 路由。 +Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上;流项沿 Host → Client 方向经 Gateway mux 传输,流的上行项则沿 Client → Host 方向走同一条逻辑流。API Gateway 拥有 Remote 传输,各控制器包分别拥有 Session、配置界面与 Workspace 行为。流式下载等不适合 Remote 调用的响应由功能包注册精确的 Connection Fetch 路由。 ----- diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index d1eaf4caeb..1803b6628a 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/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/api/gateway/README.md -README.md: 39c15e2a3e67dd09d201ae9b8d415b180015ba6f -README.zh.md: 65e1c88011824e6f2f9c7ff558a8238d850546cf +README.md: 9cf250a0578789557c02b88e2ec0c34620196644 +README.zh.md: 757b7bb45a6828e86fba31c06088020302344512 diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 39c15e2a3e..9cf250a057 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.remote`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes. Connection carries unary request correlation, trust, and response envelopes, while Gateway owns multiplexed Remote streams, including the Client-to-Host uplink of duplex methods. +Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.remote`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes. Connection carries unary request correlation, trust, and response envelopes, while Gateway owns multiplexed Remote streams, each carrying a Client-to-Host uplink on the same logical stream. ## Table of Contents @@ -28,13 +28,13 @@ Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context adapter. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. -The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and returns 404 for unclaimed requests unless an exact Fetch route owns them. Direct `invoke()` calls preserve business errors; `TypertGatewayError` is a `RemoteError` subclass whose `gateway/*` codes name the failures owned by dispatch, binding, providers, lookup, Context, arguments, codecs, and the duplex uplink: `gateway/uplink-overflow` for an uplink whose buffered frames exceed `duplexInboxBytes`, `gateway/protocol` for an uplink item after `end` or on a stream method, and the codec code `gateway/input-invalid` for a rejected uplink item. A resolver that refuses on policy grounds — a cold-resume failure or an ownership fence — throws its own `RemoteError`, and the code it chose reaches the caller unchanged. +The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and returns 404 for unclaimed requests unless an exact Fetch route owns them. Direct `invoke()` calls preserve business errors; `TypertGatewayError` is a `RemoteError` subclass whose `gateway/*` codes name the failures owned by dispatch, binding, providers, lookup, Context, arguments, codecs, and the uplink: `gateway/uplink-overflow` for an uplink whose buffered frames exceed `streamInboxBytes`, `gateway/protocol` for an uplink item after `end`, and the codec code `gateway/input-invalid` for a rejected uplink item. A resolver that refuses on policy grounds — a cold-resume failure or an ownership fence — throws its own `RemoteError`, and the code it chose reaches the caller unchanged. A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type. A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then returns a cancellation-aware iterable over the business items. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates and keeps it connected while idle. Connection owns the retry schedule; before each retry it asks the mux to cancel any candidate or active socket and make exactly one fresh physical attempt. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (two seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. A socket that has not answered the previous Ping is terminated at the next interval. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. -A duplex Remote uses `@Remote({ mode: 'duplex' })`, declares the reserved `uplink: AsyncIterable` parameter immediately before the optional `signal`, and returns an `Iterable` or `AsyncIterable` like a stream. Like `signal`, `uplink` is descriptor metadata rather than a wire argument: the Client sends each uplink item as an `item` frame on the open logical stream and its end as an `end` frame, and the Gateway decodes every item with the descriptor's uplink codec before the method reads it. The Host method observes the Client's half-close as the end of the `uplink` iteration and cancellation through `signal`; a rejected uplink item fails the whole stream with `gateway/input-invalid`, and when the method finishes its downlink the Gateway returns the `uplink` iterator and drops unread items. Each logical stream buffers at most `duplexInboxBytes` (262144 by default) of uplink frame bytes; overflow fails the stream with `gateway/uplink-overflow`, and an item after `end` or on a stream method fails it with `gateway/protocol`, in both cases without closing the socket. SRC recognizes the reserved `uplink` name, while strict generation additionally requires the global `AsyncIterable` type; an in-process carrier hands the caller's iterable to the Gateway directly. +Every stream also carries a Client-to-Host uplink on the same logical stream. A method declares the uplink item type as the second type argument of its return type, `RemoteStream` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `In` defaults to `never`, and a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream` declares that it reads none. The running method reads its call as `this.ctx.invocation`: the Gateway derives the receiver from a Context carrying that `RemoteInvocation` — `request`, `service`, `peer`, `signal`, and `uplink()` — so nothing enters the parameter list, and `ctx.invocation` is `undefined` on a Context no Remote call derived. `uplink()` is available once per call. With a descriptor codec it decodes every item before delivery; without one — an SRC method, or a method whose `In` is `never` — it delivers JSON-safe `unknown` values. The Client sends each item as an `item` frame on the open logical stream and its half-close as an `end` frame; the method observes the half-close as the end of the `uplink()` iteration and cancellation through `signal`. A rejected item fails the whole stream with `gateway/input-invalid` (field `uplink`). When the method finishes its downlink the Gateway returns the uplink iterator and drops unread items; items sent to a method that never takes its uplink wait in the inbox until then. Each logical stream buffers at most `streamInboxBytes` (262144 by default) of uplink frame bytes; overflow fails the stream with `gateway/uplink-overflow`, and an item after `end` fails it with `gateway/protocol`, in both cases without closing the socket. A unary method may also call `uplink()`; its items are readable only while the method runs. Every stream a WebSocket opens speaks for the Peer Connection admitted at upgrade, and the socket closes when that Peer's scope is disposed; an in-process carrier speaks for the operator. Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, per-Client queues, and the Host home sent in the opening `{ type: 'ready', clientId, host: { home } }` frame. Its source factory attaches incremental listeners synchronously, so the Client publishes the generation and starts baseline reads only after incremental delivery is ready. @@ -45,7 +45,7 @@ The browser carrier accepts the shell-owned stream origin described by [Connecti `ctx.remote.$mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Each namespace is a traced `remote.` child Service and unloads after its last method is withdrawn. Duplicate endpoints, namespace collisions, and Client-supplied fields without strict generated codecs fail before methods become callable. -Each unary call checks positional arity, constructs the descriptor's exact named `args`, and sends the typed values through `ctx.connection.rpc.call('/api', endpoint, ...)` without executing Client-side schemas; the Host validates the received wire fields before business invocation. A generated stream method returns an `AsyncIterable` and opens one logical stream through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. A generated duplex method additionally takes `uplink: AsyncIterable` after its business arguments: the Client sends each item the iterable yields once the stream is open and `end` when it finishes, calls the iterable's `return()` when the downlink ends first, and cancels the stream and fails the downlink iterator when the iterable throws. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before invoking the carrier. Successful unary results and stream items pass through without Client-side type parsing. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject. +Each unary call checks positional arity, constructs the descriptor's exact named `args`, and sends the typed values through `ctx.connection.rpc.call('/api', endpoint, ...)` without executing Client-side schemas; the Host validates the received wire fields before business invocation. A generated stream method returns a `RemoteStreamHandle` — the `RemoteStream` alias of `@deepseek-ai/dsh-typert-protocol/client` — and opens one logical stream when it is called, through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. The handle iterates the downlink once. `send(item)` queues an uplink item, sent after the `open` frame; `end()` half-closes the uplink; `dispose()` sends `cancel` unless a terminal frame has arrived and ends the iteration quietly. Breaking out of the iteration early disposes the handle; after the downlink terminates `send` throws and `end` is ignored. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before invoking the carrier. Successful unary results and stream items pass through without Client-side type parsing. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject. Every unary call resolves to `RemoteResult` — `{ ok: true, value }` or `{ ok: false, error }` — and never rejects for a carrier problem: this face folds an offline carrier into the error branch and answers `gateway/cancelled` when the caller's signal aborts, so no consumer wraps a call to recover one. Only an assembly fault still rejects: wrong arity, an unmounted method, a withdrawn contribution, a missing Context adapter. `error` is a live `RemoteError` instance, so `throw result.error` keeps throw semantics, and `isRemoteFailure(value)` is the one predicate a consumer needs — a caught value it accepts carries a Host code, and anything it rejects is a local fault the caller should let crash. `carrierFailure(endpoint, error)` and `cancelledFailure(endpoint, cause)` build those two folded results, so a test stand-in for this face folds identically. @@ -81,7 +81,7 @@ No direct effect; invoked business Services own any model-visible result. - Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key. - Forwarded events reach `$on` without business-payload projection or redaction. Ordinary notifications are not replayed after reconnect; Agent-scoped waterfalls project only the top-level Agent identity needed to select the Client Context and carry their own pending lifetime. - `websocketHeartbeatIntervalMs` is both the Ping cadence and the Pong deadline. The Host terminates a peer that does not answer before the next interval, so a deployment whose event loop or network can stall longer than this interval must raise it. -- Duplex uplinks have no flow control beyond the bounded Host inbox: a Client that sends faster than the method reads fails its stream with `gateway/uplink-overflow`, and no uplink item is replayed across carrier generations; a domain that must resume an uplink carries its own acknowledgement cursor in the reopened request. +- Uplinks have no flow control beyond the bounded Host inbox: a Client that sends faster than the method reads, or that sends to a method never taking its uplink, fails its stream with `gateway/uplink-overflow`, and no uplink item is replayed across carrier generations; a domain that must resume an uplink carries its own acknowledgement cursor in the reopened request. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 65e1c88011..757b7bb45a 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -为 Host 与 Client 两侧的 Cordis 环境提供 Typert RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 约定,并将业务选择交给 API Remotes。Connection 承载一元调用的请求关联、信任和响应 envelope,Gateway 则拥有多路复用的 Remote 流,包括双工方法从 Client 到 Host 的上行(uplink)。 +为 Host 与 Client 两侧的 Cordis 环境提供 Typert RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 约定,并将业务选择交给 API Remotes。Connection 承载一元调用的请求关联、信任和响应 envelope,Gateway 则拥有多路复用的 Remote 流,每条流都在同一条逻辑流上携带从 Client 到 Host 的上行(uplink)。 ## 目录 @@ -28,13 +28,13 @@ kind: "package-reference" 严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的解析器:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context 适配器解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 -Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领且没有精确 Fetch 路由负责的请求返回 404。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 是 `RemoteError` 的子类,其 `gateway/*` 码命名了分发、绑定、提供方、查找、Context、参数、编解码器以及双工上行各自负责的故障:缓冲帧超过 `duplexInboxBytes` 的上行报 `gateway/uplink-overflow`,`end` 之后或发给流方法的上行项报 `gateway/protocol`,被 codec 拒绝的上行项报编解码码 `gateway/input-invalid`。因策略而拒绝的解析器——冷恢复失败或 ownership fence——抛出自己的 `RemoteError`,它选定的码原样到达调用方。 +Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领且没有精确 Fetch 路由负责的请求返回 404。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 是 `RemoteError` 的子类,其 `gateway/*` 码命名了分发、绑定、提供方、查找、Context、参数、编解码器以及上行各自负责的故障:缓冲帧超过 `streamInboxBytes` 的上行报 `gateway/uplink-overflow`,`end` 之后的上行项报 `gateway/protocol`,被 codec 拒绝的上行项报编解码码 `gateway/input-invalid`。因策略而拒绝的解析器——冷恢复失败或 ownership fence——抛出自己的 `RemoteError`,它选定的码原样到达调用方。 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再返回可取消的业务项 iterable。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,并让它在空闲时保持连接。Connection 拥有重试调度;每次 retry 前,它要求 mux 取消候选或活动 socket,并且只做一次全新的物理连接尝试。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 2 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote 流帧。若 socket 尚未回复上一次 Ping,Host 会在下一间隔终止它。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 -双工 Remote 使用 `@Remote({ mode: 'duplex' })`,在可选的 `signal` 之前紧邻声明保留参数 `uplink: AsyncIterable`,并像流方法一样返回 `Iterable` 或 `AsyncIterable`。与 `signal` 相同,`uplink` 是 descriptor 元数据而不是 wire 参数:Client 在已打开的逻辑流上把每个上行项作为 `item` 帧发送,并以 `end` 帧表示上行结束;Gateway 在方法读取每一项之前用描述符的 uplink codec 解码它。Host 方法通过 `uplink` 迭代结束感知 Client 的半关闭,通过 `signal` 感知取消;被拒绝的上行项使整条流以 `gateway/input-invalid` 失败,方法结束下行时 Gateway 会调用 `uplink` 迭代器的 return 并丢弃未读的项。每条逻辑流最多缓冲 `duplexInboxBytes`(默认 262144)字节的上行帧;超限使流以 `gateway/uplink-overflow` 失败,`end` 之后的项或发给流方法的项使流以 `gateway/protocol` 失败,两种情况都不关闭 socket。SRC 识别保留名 `uplink`,严格生成还要求它具有全局 `AsyncIterable` 类型;进程内载体把调用方的 iterable 直接交给 Gateway。 +每条流还在同一条逻辑流上携带从 Client 到 Host 的上行。方法把上行项类型声明为返回类型的第二个类型参数——来自 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `RemoteStream`;`In` 缺省为 `never`,返回 `Iterable`、`AsyncIterable` 或 `RemoteStream` 的方法即声明自己不读上行。运行中的方法通过 `this.ctx.invocation` 读取本次调用:Gateway 从携带该 `RemoteInvocation`(`request`、`service`、`peer`、`signal` 与 `uplink()`)的 Context 派生接收者,因此没有任何东西进入参数列表,而非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`。`uplink()` 每次调用只能取一次。描述符带 codec 时它在交付前逐项解码;不带时——SRC 方法,或 `In` 为 `never` 的方法——它交付经 JSON 安全校验的 `unknown` 值。Client 在已打开的逻辑流上把每一项作为 `item` 帧发送,以 `end` 帧表示半关闭;方法通过 `uplink()` 迭代结束感知半关闭,通过 `signal` 感知取消。被拒绝的项使整条流以 `gateway/input-invalid`(字段 `uplink`)失败。方法结束下行时 Gateway 调用 uplink 迭代器的 return 并丢弃未读的项;发给从未取用 uplink 的方法的项在 inbox 里等到那时为止。每条逻辑流最多缓冲 `streamInboxBytes`(默认 262144)字节的上行帧;超限使流以 `gateway/uplink-overflow` 失败,`end` 之后的项使流以 `gateway/protocol` 失败,两种情况都不关闭 socket。一元方法也可以调用 `uplink()`;其项只在方法运行期间可读。一条 WebSocket 打开的所有流都代表 Connection 在升级时接纳的 Peer,该 Peer 的 scope 释放时 socket 关闭;进程内载体代表操作者。 Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的流。事件名单、参数校验、每个 Client 的队列及 opening `{ type: 'ready', clientId, host: { home } }` frame 中的 Host home 由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener,因此 Client 只在增量投递就绪后发布 generation 并开始 baseline 读取。 @@ -45,7 +45,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source `ctx.remote.$mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。每个 namespace 都是可追踪的 `remote.` 子 Service,并在最后一个方法撤回后卸载。重复端点、命名空间冲突,以及缺少生成的严格 codec 的 Client 供值字段,都会在方法可调用前报错。 -每次一元调用都会检查位置参数数量,构造与描述符完全匹配的具名 `args`,再把带类型的值原样交给 `ctx.connection.rpc.call('/api', endpoint, ...)`,而不执行 Client 侧 schema;Host 会在业务调用前校验收到的 wire 字段。生成的流方法返回 `AsyncIterable`,并在进程内 Connection 载体可用时通过它打开逻辑流,否则通过共享的 Gateway WebSocket 打开。生成的双工方法还在业务参数之后接受 `uplink: AsyncIterable`:流打开后 Client 逐项发送该 iterable 产出的项,迭代结束时发送 `end`;下行先结束时调用该 iterable 的 `return()`;iterable 抛错时取消整条流,并让下行迭代器以该错误失败。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。成功的一元结果与流项不经 Client 侧类型解析直接传递。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。 +每次一元调用都会检查位置参数数量,构造与描述符完全匹配的具名 `args`,再把带类型的值原样交给 `ctx.connection.rpc.call('/api', endpoint, ...)`,而不执行 Client 侧 schema;Host 会在业务调用前校验收到的 wire 字段。生成的流方法返回 `RemoteStreamHandle`——即 `@deepseek-ai/dsh-typert-protocol/client` 的 `RemoteStream` 别名——并在被调用时就打开一条逻辑流,进程内 Connection 载体可用时通过它打开,否则通过共享的 Gateway WebSocket 打开。句柄只迭代下行一次。`send(item)` 把一个上行项入队,在 `open` 帧之后发出;`end()` 半关闭上行;`dispose()` 在未收到终止帧时发送 `cancel`,并让迭代安静结束。提前跳出迭代等价于 dispose 句柄;下行终止后 `send` 抛错,`end` 被忽略。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。成功的一元结果与流项不经 Client 侧类型解析直接传递。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。 每次一元调用都解析为 `RemoteResult`——`{ ok: true, value }` 或 `{ ok: false, error }`——且绝不因载体问题 reject:本面把断线载体折入错误分支,调用方 signal 中止时答以 `gateway/cancelled`,因此没有消费方需要包一层来兜载体失败。只有装配故障仍会 reject:参数个数不符、方法未挂载、贡献已撤下、缺少 Context 适配器。`error` 是活的 `RemoteError` 实例,所以 `throw result.error` 保持 throw 语义;而 `isRemoteFailure(value)` 是消费方唯一需要的谓词——它认下的捕获值带着 Host 码,它拒绝的一律是本地故障,调用方应当让其崩掉。`carrierFailure(endpoint, error)` 与 `cancelledFailure(endpoint, cause)` 构造这两种折叠结果,测试里的替代实现据此采用相同的折叠方式。 @@ -81,7 +81,7 @@ Client waterfall 的 Context 解析保持同步。解析器可以返回借用的 - lookup 解析器按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。 - 被转发的事件到达 `$on` 时不做业务载荷投影或脱敏。普通通知在重连后不重放;Agent-scoped waterfall 只投影选择 Client Context 所需的顶层 Agent 身份,并自行携带 pending 生命周期。 - `websocketHeartbeatIntervalMs` 同时是 Ping 周期和 Pong 截止时间。对端未在下一周期前回复时,Host 会终止连接;如果部署的事件循环或网络可能停顿超过该间隔,必须调大此配置。 -- 双工上行除了有界的 Host inbox 之外没有流控:Client 发送快于方法读取时,其流以 `gateway/uplink-overflow` 失败;上行项不会跨载体代际重放,需要恢复上行的领域在重开的请求里自带确认游标。 +- 上行除了有界的 Host inbox 之外没有流控:Client 发送快于方法读取,或发给从未取用 uplink 的方法时,其流以 `gateway/uplink-overflow` 失败;上行项不会跨载体代际重放,需要恢复上行的领域在重开的请求里自带确认游标。 diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index b2a87ce222..1d44861ee8 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -573,13 +573,13 @@ class ClientStreamHandle implements RemoteStreamHandle { this.generation.abort(new Error(`client api: ${this.endpoint} stream disposed`)) } - [Symbol.asyncIterator](): AsyncGenerator { + [Symbol.asyncIterator](): AsyncGenerator { if (this.consumed) throw new Error(`client api: ${this.endpoint} stream has one consumer`) this.consumed = true return this.iterate() } - private async *iterate(): AsyncGenerator { + private async *iterate(): AsyncGenerator { try { while (true) { const next = await (this.primed ?? this.downlink.next()) diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 3add5df299..91c66214fd 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -1225,7 +1225,8 @@ class GatewayInvocation implements RemoteInvocation { /** * The downlink finished: release the uplink. Unread items are dropped, and a - * carrier iterable the method never took is returned so it stops producing. + * carrier iterable the method never took is returned so it stops producing; + * a later `uplink()` throws like a second one would. * @returns settles once a taken uplink has closed. */ async close(): Promise { @@ -1233,7 +1234,6 @@ class GatewayInvocation implements RemoteInvocation { await this.decoder.return() return } - if (this.taken) return this.taken = true // The carrier owns the source iterator; its return() is not awaited for // the same reason UplinkDecoder.return() does not await it. diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index bb1b5ff08a..7906cf21a8 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -423,6 +423,11 @@ describe('Typert Remote streams', () => { ]) expect(service.peers.at(-1)?.id).toBe('in-process-operator') expect(service.invocationOutsideCall()).toBeUndefined() + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'context', args: { label: 'again' }, + }))).resolves.toHaveLength(2) + expect(service.peers.at(-1)).toBe(service.peers.at(-2)) + await expect(service.peers.at(-1)?.dispose()).resolves.toBeUndefined() // Items sent to a method that never takes its uplink wait in the carrier and are dropped when the downlink ends. await expect(collect(await ctx.typertGateway.stream({ @@ -438,6 +443,29 @@ describe('Typert Remote streams', () => { }) }) + it('releases a taken uplink source even when its return() rejects', async () => { + const { ctx } = await setup(false) + const abort = new AbortController() + const returned = vi.fn(async (): Promise> => { + throw new Error('fixture release failure') + }) + const stuck: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + next: () => new Promise>(() => {}), + return: returned, + }), + } + const source = await ctx.typertGateway.stream({ + namespace: 'feed', method: 'hold', args: {}, uplink: stuck, signal: abort.signal, + }) + const iterator = source[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'held' }) + const pending = iterator.next() + abort.abort(new Error('fixture cancellation')) + await expect(pending).rejects.toThrow('Remote invocation "feed/hold" was aborted') + await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) + }) + it('lets a unary method read uplink items while it runs', async () => { const { ctx } = await setup(false) await expect(ctx.typertGateway.invoke({ diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index 2a729d63a7..e0c7a2cfef 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -2279,7 +2279,10 @@ describe('Client Typert API', () => { }) ctx.set('connection', undefined) expect(() => ctx.remote.probe.watch('offline')).toThrow('probe/watch has no active Connection') + // A method function read before the withdrawal keeps its mount token and refuses afterwards. + const retained = ctx.remote.probe.watch await secondDispose() + expect(() => retained('withdrawn')).toThrow('Remote method probe/watch is no longer mounted') }) it('publishes a namespace only after every contributed method is installed', async () => { @@ -2467,7 +2470,7 @@ describe('Client Typert API', () => { expect(mock.log.streams('probe/attach').map(open => open.args)).toEqual([[{ topic: 'alpha' }], [{ topic: 'beta' }]]) // Arity is a runtime contract, so the topic is deliberately omitted through Reflect. - expect(() => Reflect.apply(ctx.remote.probe.attach, ctx.remote.probe, [])).toThrow( + expect(() => { Reflect.apply(ctx.remote.probe.attach, ctx.remote.probe, []) }).toThrow( 'client api: probe/attach expected 1 business argument(s) plus an optional AbortSignal, got 0', ) expect(call).not.toHaveBeenCalled() @@ -2612,6 +2615,40 @@ describe('Remote stream client carrier lifecycle', () => { }) }) + it('fails the downlink and cancels when an uplink iterable throws, and swallows a rejecting uplink return()', async () => { + await withFakeWebSocket('https://harness.example', async () => { + const client = new RemoteStreamMuxClient() + client.start() + const socket = FakeWebSocket.sockets[0]! + const exploding = (async function* (): AsyncGenerator { + yield 'a' + throw new Error('uplink exploded') + })() + const pending = client.open('probe/attach', {}, new AbortController().signal, exploding)[Symbol.asyncIterator]().next() + await expect(pending).rejects.toThrow('uplink exploded') + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'item', 'cancel']) + + const returned = vi.fn(async (): Promise> => { + throw new Error('fixture release failure') + }) + const stuck: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + next: () => new Promise>(() => {}), + return: returned, + }), + } + const abort = new AbortController() + const second = client.open('probe/attach', {}, abort.signal, stuck)[Symbol.asyncIterator]().next() + await vi.waitFor(() => { expect(socket.sent).toHaveLength(4) }) + const reason = new Error('caller left') + abort.abort(reason) + await expect(second).rejects.toBe(reason) + await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'item', 'cancel', 'open', 'cancel']) + await client.close() + }) + }) + it('requires the transport owner to start the physical carrier', async () => { const client = new RemoteStreamMuxClient() await expect(client.open('feed/follow', {}, new AbortController().signal) diff --git a/packages/api/gateway/tests/stream-server.host.spec.ts b/packages/api/gateway/tests/stream-server.host.spec.ts index 3ab3c44b3b..6a8a2fdb00 100644 --- a/packages/api/gateway/tests/stream-server.host.spec.ts +++ b/packages/api/gateway/tests/stream-server.host.spec.ts @@ -272,9 +272,9 @@ describe('Remote stream mux server Peer binding', () => { const closed = once(client, 'close') await peer.dispose() - const [code, reason] = await closed - expect(code).toBe(1001) - expect(String(reason)).toBe('peer left') + const closeEvent = await closed + expect(closeEvent[0]).toBe(1001) + expect(String(closeEvent[1])).toBe('peer left') }) }) diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 16135d1ee3..074338dc60 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/connection/README.md -README.md: b15d8eff30d48aa5978c89bb43c8e24d8a51cc70 -README.zh.md: dd01a84d0721d8114d66964996f548bf474e22b9 +README.md: 136fd507da31c8ecf72750df0ba4613f0968716a +README.zh.md: 192780683ac3ba1d344436bee364a593c6d2bfc9 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index b15d8eff30..136fd507da 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -27,7 +27,7 @@ The package carries browser-to-Host Remote calls, exact Fetch responses, and con A static desktop page can provide `__DSH_TRANSPORT__.streamBaseUrl` for the HTTP origin of its owned Host. The Gateway uses that origin for its WebSocket while HTTP transport remains independently selected. The desktop carrier owns authentication; setting the origin alone grants no access. -The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; shell-owned compositions provide equivalent Remote streams, including a duplex method's uplink, through `connection.rpc.open` without opening a WebSocket. The browser plugin reads the page transport, recovery settings, and location, then delegates to `installConnection(ctx, options)`. A composition that owns its carrier may call the same installer directly; the whole-client test tier does so. Each invocation creates one Context-owned service, so several Client trees can use different carriers in one realm. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md). +The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; shell-owned compositions provide equivalent Remote streams, including a stream's uplink, through `connection.rpc.open` without opening a WebSocket. The browser plugin reads the page transport, recovery settings, and location, then delegates to `installConnection(ctx, options)`. A composition that owns its carrier may call the same installer directly; the whole-client test tier does so. Each invocation creates one Context-owned service, so several Client trees can use different carriers in one realm. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md). ----- @@ -40,6 +40,8 @@ The cookie signing secret is the owner-scoped `client-connection/browser-session Before authentication, every request still passes `src/api-request-trust.ts`. Its `Host` must be loopback or match a `trustedHosts` entry: exact on `host:port`, any port on port-less entries, both sides WHATWG-normalized. An attached `Origin` must equal that Host and `sec-fetch-site: cross-site` is refused. Malformed configured authorities fail plugin load. These checks defend DNS rebinding and cross-site browser requests; they never establish identity. A failed Host/Origin check returns 403, while a trusted but unauthenticated request returns 401. `dsh web --host 0.0.0.0` remains unsupported. Decision records: [browser request trust](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md) and [browser token authentication](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md). +Every admitted request speaks for a Peer. `ctx.connection.peers` is the Host-wide `PeerRegistry`: `operator` is the process's own Peer; `open()` admits another party and returns a `PeerScope` whose `ctx` owns connection-lifetime registrations until `dispose()`; `bind(carrier, peer)` and `of(carrier)` associate a node request, upgrade request, WebSocket, or Fetch `Request` with the Peer it was admitted as. `ctx.connection.admit(request)` answers from a binding when one exists and otherwise runs the trust and authentication checks and answers with the operator; the `/api` route and the Gateway's WebSocket upgrade admit through it, and every RPC handler receives the Peer of its call. Who a Peer is and what it may do are not recorded here: the admitter attaches that through `peer.ctx` or a registry keyed by the scope. + Authenticated shared HTTP requests pass through the `connection/request` waterfall before body transfer. A listener may refuse new requests or await `next()` through response completion; removing its owning fiber removes admission behavior. Desktop uses this hook to lock new API work during an approved installation without canceling already-admitted work. WebSocket stream ownership remains with API Gateway. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index dd01a84d07..192780683a 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" 静态桌面页面可以通过 `__DSH_TRANSPORT__.streamBaseUrl` 提供其所拥有 Host 的 HTTP origin。Gateway 将该 origin 用于 WebSocket,HTTP 传输仍独立选择。桌面载体负责认证;仅设置 origin 不会授予访问权限。 -浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流(包括双工方法的上行),不打开 WebSocket。浏览器插件读取页面 transport、恢复设置与 location,再委托 `installConnection(ctx, options)`。持有自身载体的组合可以直接调用同一个安装函数;整机客户端测试档就是这一消费者。每次调用都会创建一个归所属 Context 的服务,因此同一 realm 中的多棵 Client 树可以使用不同载体。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD`/`POST` 路由注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。 +浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流(包括流的上行),不打开 WebSocket。浏览器插件读取页面 transport、恢复设置与 location,再委托 `installConnection(ctx, options)`。持有自身载体的组合可以直接调用同一个安装函数;整机客户端测试档就是这一消费者。每次调用都会创建一个归所属 Context 的服务,因此同一 realm 中的多棵 Client 树可以使用不同载体。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD`/`POST` 路由注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。 ----- @@ -40,6 +40,8 @@ cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-sessi 认证之前,每个请求仍经过 `src/api-request-trust.ts`。其 `Host` 必须是 loopback,或与 `trustedHosts` 条目匹配:带端口的 `host:port` 精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化。若附带 `Origin`,它必须等于该 Host;`sec-fetch-site: cross-site` 一律拒绝。畸形配置 authority 会让插件加载失败。这些检查防御 DNS rebinding 与跨站浏览器请求,绝不建立身份。Host/Origin 校验失败返回 403;Host 可信但未认证的请求返回 401。`dsh web --host 0.0.0.0` 仍不受支持。决策记录:[浏览器请求信任](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)与[浏览器令牌认证](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md)。 +每个被接纳的请求都代表一个 Peer。`ctx.connection.peers` 是 Host 范围的 `PeerRegistry`:`operator` 是进程自己的 Peer;`open()` 接纳另一方并返回一个 `PeerScope`,其 `ctx` 拥有连接期注册直到 `dispose()`;`bind(carrier, peer)` 与 `of(carrier)` 把 node 请求、升级请求、WebSocket 或 Fetch `Request` 与接纳它的 Peer 关联。`ctx.connection.admit(request)` 在存在绑定时按绑定作答,否则执行信任与认证检查并以操作者作答;`/api` 路由与 Gateway 的 WebSocket 升级都经它接纳,每个 RPC 处理器都收到本次调用的 Peer。Peer 是谁、能做什么不记录在这里:接纳方通过 `peer.ctx` 或按 scope 键的注册表自行附加。 + 通过认证的共享 HTTP 请求在传输请求体之前经过 `connection/request` waterfall。监听器可以拒绝新请求,或等待 `next()` 直到响应完成;释放所属 fiber 会移除准入行为。Desktop 使用此扩展点,在已批准的安装期间锁住新的 API 工作,而不取消已接纳的工作。WebSocket 流仍由 API Gateway 负责。 diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 3a684757eb..8b95205a05 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -35,8 +35,6 @@ "license": "MIT", "dependencies": { "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", "zod": "^4.4.3" }, @@ -46,7 +44,8 @@ "lib/types/**/*.d.ts" ], "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -54,6 +53,8 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^" + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^" } } diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index b2d4ce11d5..ccf69c9b32 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -8,7 +8,16 @@ import type { AddressInfo } from 'node:net' import type { IncomingMessage, ServerResponse } from 'node:http' import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { IndexInjection, WebServer, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' -import { API_PATH, RpcId, apply, inject, type ClientRequest, type ConnectionConfig, type HostConnectionHandle } from '../src/index.ts' +import { + API_PATH, + RpcId, + apply, + inject, + type ClientRequest, + type ConnectionConfig, + type HostConnectionHandle, + type PeerScope, +} from '../src/index.ts' import { DEFAULT_MAX_REQUEST_BODY_BYTES } from '../src/http-bridge.ts' import { provideBrowserCredentials } from './browser-credentials.ts' @@ -469,6 +478,54 @@ describe('connection node half', () => { await fiber.dispose() }) + it('admits a bound carrier as its Peer and hands every call the Peer it speaks for', async () => { + const { connection, routes, dispose } = await mounted({ trustedHosts: ['harness.example'] }) + const peers: PeerScope[] = [] + const remove = connection.rpc.intercept( + '/api', + () => true, + async (_endpoint, _payload, _signal, peer) => { + peers.push(peer) + return { ok: true, value: null } + }, + ) + const admitted = connection.peers.open() + expect(admitted).not.toBe(connection.peers.operator) + const untrusted = fakeRequest({ host: 'other.example' }) + expect(connection.admit(untrusted)).toEqual({ rejection: 403 }) + connection.peers.bind(untrusted, admitted) + expect(connection.admit(untrusted)).toEqual({ peer: admitted }) + expect(connection.peers.of({})).toBeUndefined() + + const route = routes.find(candidate => candidate.path === API_PATH)! + const request: ClientRequest = { + type: 'client-request', + rpcId: RpcId('rpc-peer'), + method: 'goals/create', + payload: { args: {} }, + } + const boundPost = fakePost({ host: 'other.example' }, '/api/goals/create', request) + connection.peers.bind(boundPost, admitted) + const answered = fakeResponse() + await route.handler(boundPost, answered.response) + expect(JSON.parse(String(answered.state.body))).toMatchObject({ result: { ok: true, value: null } }) + const cookie = browserCookie(connection, '127.0.0.1:3080') + await route.handler(fakePost({ host: '127.0.0.1:3080', cookie }, '/api/goals/create', request), fakeResponse().response) + // A shell-owned carrier dispatches the shared handler without the bridge; nobody bound its Request. + const direct = await connection.createSharedFetchHandler('/api').fetch(new Request('http://127.0.0.1:3080/api/goals/create', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(request), + })) + expect(direct.status).toBe(200) + expect(peers).toEqual([admitted, connection.peers.operator, connection.peers.operator]) + + await admitted.dispose() + await admitted.dispose() + await remove() + await dispose() + }) + it('applies the configured trust fence and JSON envelope checks to generic channels', async () => { const ctx = new Context() const routes: WebRoute[] = [] diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 637a6935af..d15c4626b1 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -735,8 +735,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'connection', - summary: 'Host `ctx.connection` shape consumed by transport-independent adapters.', - description: 'Host `ctx.connection` shape consumed by transport-independent adapters.', + summary: 'Host `ctx.connection` members consumed by transport-independent adapters.', + description: 'Host `ctx.connection` members consumed by transport-independent adapters.', methods: [ { signature: 'readonly rpc: HostConnectionRpc', @@ -748,6 +748,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Exact Fetch routes for streaming or browser-native responses.', parameters: [], }, + { + signature: 'readonly peers: PeerRegistryHandle', + description: 'Peers this Host answers to.', + parameters: [], + }, { signature: 'createSharedFetchHandler(channel: \'/api\'): ConnectionFetchHandler', description: 'Compose exact Fetch routes and the shared-channel RPC interceptor.', @@ -760,6 +765,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'request', description: 'request headers from the HTTP or upgrade request.' }], returns: 'rejection status, or undefined when the route may accept the request.', }, + { + signature: 'admit(request: ConnectionTrustRequest): PeerAdmission', + description: 'Decide which Peer one request speaks for: a carrier bound through `peers` answers from its binding; every other request passes requestRejection and speaks for the operator.', + parameters: [{ name: 'request', description: 'request headers from the HTTP or upgrade request.' }], + returns: 'the admitted Peer, or the rejection status.', + }, { signature: 'authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean', description: 'Authenticate one frontend index request, owning a token redirect or 401.', @@ -3068,7 +3079,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Resolve strict generated definitions or conservative SRC markers against current Cordis Services and Typert providers.', methods: [ { - signature: 'readonly wireStream: TypertGatewayWireStream = { open: (endpoint, payload, uplink, signal) => this.openWireStream(endpoint, payload, uplink, signal, new AbortController()), failure: error => rpcError(error), }', + signature: 'readonly wireStream: TypertGatewayWireStream = { open: (endpoint, payload, uplink, peer, signal) => this.openWireStream(endpoint, payload, uplink, peer, signal, new AbortController()), failure: error => rpcError(error), }', description: 'Carrier adapter shared by the WebSocket mux and local Host transports.', parameters: [], }, @@ -3087,8 +3098,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async stream(request: InvokeRemoteRequest): Promise>', - description: 'Open one live stream or duplex Remote method without assuming a physical carrier.', - parameters: [{ name: 'request', description: 'decoded endpoint, named wire arguments, and the duplex uplink when the method reads one.' }], + description: 'Open one live stream Remote method without assuming a physical carrier.', + parameters: [{ name: 'request', description: 'decoded endpoint, named wire arguments, and the Client uplink when the carrier has one.' }], returns: 'a cancellation-aware iterable over the business results.', }, ], @@ -4409,7 +4420,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ConnectionRpcHandler', - declaration: 'export type ConnectionRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal) => Promise>;', + declaration: 'export type ConnectionRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal, peer: PeerScope) => Promise>;', }, { name: 'ConnectionRpcResult', @@ -4925,7 +4936,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'InvocationDescriptor', - declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly mode?: \'stream\' | \'duplex\';\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypertCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly uplink?: {\n readonly parameter: \'uplink\';\n readonly codec: TypertCodec;\n };\n readonly cancellation?: {\n readonly parameter: \'signal\';\n };\n readonly result: TypertCodec;\n readonly sourceLocation?: InvocationSourceLocation;\n}', + declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly mode?: \'stream\';\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypertCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly uplink?: {\n readonly codec: TypertCodec;\n };\n readonly cancellation?: {\n readonly parameter: \'signal\';\n };\n readonly result: TypertCodec;\n readonly sourceLocation?: InvocationSourceLocation;\n}', }, { name: 'InvocationParameterDescriptor', @@ -4937,7 +4948,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'InvokeRemoteRequest', - declaration: 'export interface InvokeRemoteRequest {\n readonly namespace: string;\n readonly method: string;\n readonly args: Readonly>;\n readonly uplink?: AsyncIterable;\n readonly signal?: AbortSignal;\n}', + declaration: 'export interface InvokeRemoteRequest {\n readonly namespace: string;\n readonly method: string;\n readonly args: Readonly>;\n readonly uplink?: AsyncIterable;\n readonly peer?: PeerScope;\n readonly signal?: AbortSignal;\n}', }, { name: 'JobDoneListener', @@ -5315,6 +5326,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PackageResult', declaration: 'export interface PackageResult {\n exitCode: number;\n output: string;\n truncated: boolean;\n logPath: string;\n kind?: PluginInstallFailureKind;\n}', }, + { + name: 'PeerAdmission', + declaration: 'export type PeerAdmission = {\n readonly peer: PeerScope;\n} | {\n readonly rejection: 401 | 403;\n};', + }, + { + name: 'PeerId', + declaration: 'export type PeerId = Branded<\'PeerId\'>;', + }, + { + name: 'PeerRegistryHandle', + declaration: 'export interface PeerRegistryHandle {\n readonly operator: PeerScope;\n open(): PeerScope;\n bind(carrier: object, peer: PeerScope): void;\n of(carrier: object): PeerScope | undefined;\n}', + }, + { + name: 'PeerScope', + declaration: 'export interface PeerScope {\n readonly id: PeerId;\n readonly ctx: Context;\n dispose(): Promise;\n}', + }, { name: 'PermissionCatalog', declaration: 'export interface PermissionCatalog {\n options: PresetOption[];\n}', @@ -6933,7 +6960,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TypertGatewayWireStream', - declaration: 'export interface TypertGatewayWireStream {\n readonly open: (endpoint: string, payload: unknown, uplink: AsyncIterable, signal: AbortSignal) => Promise>;\n readonly failure: (error: unknown) => {\n readonly code: string;\n readonly message: string;\n readonly details: object;\n };\n}', + declaration: 'export interface TypertGatewayWireStream {\n readonly open: (endpoint: string, payload: unknown, uplink: AsyncIterable, peer: PeerScope | undefined, signal: AbortSignal) => Promise>;\n readonly failure: (error: unknown) => {\n readonly code: string;\n readonly message: string;\n readonly details: object;\n };\n}', }, { name: 'TypertMemberModel', diff --git a/packages/test-support/remote-mock/README.i18n.yaml b/packages/test-support/remote-mock/README.i18n.yaml index b34a83ebed..b277653e91 100644 --- a/packages/test-support/remote-mock/README.i18n.yaml +++ b/packages/test-support/remote-mock/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/test-support/remote-mock/README.md -README.md: 82979e37052aecee6ec716702f634d25c969f5ad -README.zh.md: 201ca011410dd5a4d65b6ebdfb0c47f23742b0d3 +README.md: 1044aeb7207c96441296f9bbaa794902c338f9af +README.zh.md: c3e3818f04dabbeca3163bd342d8dcd919e3dd1b diff --git a/packages/test-support/remote-mock/README.md b/packages/test-support/remote-mock/README.md index 82979e3705..1044aeb720 100644 --- a/packages/test-support/remote-mock/README.md +++ b/packages/test-support/remote-mock/README.md @@ -73,7 +73,9 @@ mock.streams.fail('session/follow', new Error('gone')) await mock.streams.drained('session/follow') ``` -A failed stream rejects the consumer's next read with the given `Error`. Consumer cancellation (the opening signal or an early iterator `return()`) aborts `StreamHandle.signal`, ends the iteration without throwing, and logs the stream as `cancelled`. +A failed stream rejects the consumer's next read with the given `Error`. Consumer cancellation (the opening signal or an early iterator `return()`) aborts `StreamHandle.signal`, ends the iteration without throwing, and logs the stream as `cancelled`. `StreamHandle.uplink` is the uplink iterable the caller passed with the open through `rpc.open`, or an immediately ended iterable when it passed none; the uplink stays out of the logged args. + +A fake that stands in for a generated stream method returns the `RemoteStreamHandle` the generated method does. `streamHandle(source)` types an `AsyncIterable` as that handle with inert `send`, `end`, and `dispose`; `streamMethod(generator)` lifts an async generator function written for the method's arguments into the method's own signature, for `vi.fn()` and `mockImplementation`. ### Connect a client @@ -108,7 +110,7 @@ A failed stream rejects the consumer's next read with the given `Error`. Consume | [`src/index.ts`](src/index.ts) | Public face re-exports | | [`src/remote-mock.ts`](src/remote-mock.ts) | `RemoteMock`: default responses, native mocks, Connection dispatch, controlled streams and missing-response checks; `ok` | | [`src/remote-proxy.ts`](src/remote-proxy.ts) | Namespace/method lookup and generated-map mock types | -| [`src/streams.ts`](src/streams.ts) | `frames` / `openStream` scripts and `MockStream` (handle + `AsyncIterable`) | +| [`src/streams.ts`](src/streams.ts) | `frames` / `openStream` scripts, `streamHandle` / `streamMethod` fake typing, and `MockStream` (handle + `AsyncIterable`) | | [`src/log.ts`](src/log.ts) | Log store with the shared `seq` counter | | — | No runtime invariant companion is published; this test-support library owns no production event stream or mutable process state, and its behavior is exercised by its package tests. | diff --git a/packages/test-support/remote-mock/README.zh.md b/packages/test-support/remote-mock/README.zh.md index 201ca01141..c3e3818f04 100644 --- a/packages/test-support/remote-mock/README.zh.md +++ b/packages/test-support/remote-mock/README.zh.md @@ -73,7 +73,9 @@ mock.streams.fail('session/follow', new Error('gone')) await mock.streams.drained('session/follow') ``` -失败的流让消费方的下一次读取以给定的 `Error` reject。消费方取消(打开时的 signal 或 iterator 提前 `return()`)会中止 `StreamHandle.signal`、结束迭代而不抛错,并把该流记为 `cancelled`。 +失败的流让消费方的下一次读取以给定的 `Error` reject。消费方取消(打开时的 signal 或 iterator 提前 `return()`)会中止 `StreamHandle.signal`、结束迭代而不抛错,并把该流记为 `cancelled`。`StreamHandle.uplink` 是调用方经 `rpc.open` 随打开传入的上行 iterable,未传入时是立即结束的 iterable;上行不进入记录的 args。 + +替代生成流方法的假实现返回生成方法所返回的 `RemoteStreamHandle`。`streamHandle(source)` 把一个 `AsyncIterable` 标注为该句柄,其 `send`、`end`、`dispose` 为空操作;`streamMethod(generator)` 把按方法参数编写的 async generator 函数提升为方法自己的签名,供 `vi.fn()` 与 `mockImplementation` 使用。 ### 接上客户端 @@ -108,7 +110,7 @@ await mock.streams.drained('session/follow') | [`src/index.ts`](src/index.ts) | 公开面转出 | | [`src/remote-mock.ts`](src/remote-mock.ts) | `RemoteMock`:默认响应、原生 mock、Connection 分发、受控流与缺失响应检查;`ok` | | [`src/remote-proxy.ts`](src/remote-proxy.ts) | 命名空间/方法查找与生成映射的 mock 类型 | -| [`src/streams.ts`](src/streams.ts) | `frames` / `openStream` 脚本与 `MockStream`(句柄 + `AsyncIterable`) | +| [`src/streams.ts`](src/streams.ts) | `frames` / `openStream` 脚本、`streamHandle` / `streamMethod` 假实现标注,以及 `MockStream`(句柄 + `AsyncIterable`) | | [`src/log.ts`](src/log.ts) | 带共享 `seq` 计数器的日志 | | — | 不发布运行时不变量伴生件;本测试支持库不拥有任何生产事件流或可变进程状态,其行为由本包测试覆盖。 | diff --git a/packages/test-support/remote-mock/tests/streams.client.spec.ts b/packages/test-support/remote-mock/tests/streams.client.spec.ts index ec41ba902f..5755e9fc1c 100644 --- a/packages/test-support/remote-mock/tests/streams.client.spec.ts +++ b/packages/test-support/remote-mock/tests/streams.client.spec.ts @@ -1,6 +1,7 @@ /** Stream scripts, live stream control, cancellation, and the built-in `$events` opening. */ import { describe, expect, it } from 'vitest' -import { RemoteMock, frames, openStream } from '../src/index.ts' +import type { RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol/client' +import { RemoteMock, frames, openStream, streamHandle, streamMethod } from '../src/index.ts' const idle = (): AbortSignal => new AbortController().signal @@ -184,6 +185,24 @@ describe('RemoteMock streams', () => { expect(mock.modeOf('s/declared')).toBe('stream') }) + it('types a fake stream as the generated handle, with inert uplink members', async () => { + const handle = streamHandle((async function* () { + yield 'a' + yield 'b' + })()) + handle.send('ignored') + handle.end() + handle.dispose() + await expect(drain(handle)).resolves.toEqual(['a', 'b']) + + const method = streamMethod<(prefix: string, signal?: AbortSignal) => RemoteStreamHandle>( + async function* (prefix, signal) { + yield `${prefix}:${String(signal?.aborted ?? 'none')}` + }, + ) + await expect(drain(method('hi'))).resolves.toEqual(['hi:none']) + }) + it('waits for opens with opened(), and answers $events with one ready frame per generation', async () => { const mock = RemoteMock.create({ host: { home: '/home/me' } }) const second = mock.streams.opened('$events', 2) diff --git a/packages/typert/generator/README.i18n.yaml b/packages/typert/generator/README.i18n.yaml index 26eafdb018..e35624d914 100644 --- a/packages/typert/generator/README.i18n.yaml +++ b/packages/typert/generator/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/typert/generator/README.md -README.md: 399e4fe622a785d68452ffeae79e106246941fa1 -README.zh.md: 1fd19fd76ab804013a8741cb90522e9fd3f608d3 +README.md: ad78a9108fe7b1f8178ff9866649633ed5072c60 +README.zh.md: c88b8bdd67e8c271817ff668ae4b62f7ea8b541d diff --git a/packages/typert/generator/README.md b/packages/typert/generator/README.md index 399e4fe622..ad78a9108f 100644 --- a/packages/typert/generator/README.md +++ b/packages/typert/generator/README.md @@ -79,7 +79,7 @@ The generator is built on one separation: extraction and emission are decoupled ### Analysis and faces -Host and Client are independent TypeScript programs. Direct project references establish compiler-face membership, while `dsh.client` package subpaths establish runtime-face contribution; `package.json#exports` marks every cross-package public boundary, and imports or re-exports are the only cross-face edges. A relative import that resolves inside the referencing package is followed through that module's re-exports until a package specifier appears, so package-local forwarding modules keep their original declaration references; a relative import that resolves into another package fails. `check` mode fails on syntax or semantic diagnostics, missing public annotations, private cross-package references, and reachable declaration merges the model cannot retain losslessly; `write` mode inserts checker-derived annotations and returns a clean check-mode model. Types owned by NPM dependencies remain `external` references instead of being expanded. +Host and Client are independent TypeScript programs. Direct project references establish compiler-face membership, while `dsh.client` package subpaths establish runtime-face contribution; `package.json#exports` marks every cross-package public boundary, and imports or re-exports are the only cross-face edges. A relative import that resolves inside the referencing package is followed through that module's re-exports until a package specifier appears, so package-local forwarding modules keep their original declaration references; a relative import that resolves into another package fails. `check` mode fails on syntax or semantic diagnostics, missing public annotations, private cross-package references, and reachable declaration merges the model cannot retain losslessly; `write` mode inserts checker-derived annotations and returns a clean check-mode model. Types owned by NPM dependencies remain `external` references instead of being expanded. A stream method's return type may be `Iterable`, `AsyncIterable`, or the protocol's `RemoteStream`, recognized by symbol and declaring package like the standard-library wrappers; a second type argument other than `never` yields the descriptor's uplink codec, and the generated Client signature returns `RemoteStreamHandle` from the protocol's Client entry. ### Emission and publication contract diff --git a/packages/typert/generator/README.zh.md b/packages/typert/generator/README.zh.md index 1fd19fd76a..c88b8bdd67 100644 --- a/packages/typert/generator/README.zh.md +++ b/packages/typert/generator/README.zh.md @@ -79,7 +79,7 @@ files: ### 分析与 face -Host 与 Client 是两个独立的 TypeScript 程序。直接项目引用确定编译器 face 的成员归属,`dsh.client` 包子路径则确定运行时 face 的贡献;`package.json#exports` 划定所有跨包公开边界,跨 face 的边只能来自导入或重新导出。解析到本包内部模块的相对导入会沿该模块的重新导出继续追踪,直到出现包说明符,因此包内转发模块保留原始声明引用;解析到其他包的相对导入会失败。`check` 模式遇到语法或语义诊断、缺失的公开类型标注、跨包私有引用,以及模型无法无损保留的可达声明合并时都会失败;`write` 模式插入类型检查器推导出的标注,并返回无诊断的 check 模式模型。NPM 依赖拥有的类型继续以 `external` 引用表示,不会被展开。 +Host 与 Client 是两个独立的 TypeScript 程序。直接项目引用确定编译器 face 的成员归属,`dsh.client` 包子路径则确定运行时 face 的贡献;`package.json#exports` 划定所有跨包公开边界,跨 face 的边只能来自导入或重新导出。解析到本包内部模块的相对导入会沿该模块的重新导出继续追踪,直到出现包说明符,因此包内转发模块保留原始声明引用;解析到其他包的相对导入会失败。`check` 模式遇到语法或语义诊断、缺失的公开类型标注、跨包私有引用,以及模型无法无损保留的可达声明合并时都会失败;`write` 模式插入类型检查器推导出的标注,并返回无诊断的 check 模式模型。NPM 依赖拥有的类型继续以 `external` 引用表示,不会被展开。流方法的返回类型可以是 `Iterable`、`AsyncIterable` 或协议包的 `RemoteStream`,后者与标准库包装器一样按符号名与声明所在包识别;第二个类型参数不为 `never` 时产出描述符的上行编解码器,生成的 Client 签名则返回来自协议包 Client 入口的 `RemoteStreamHandle`。 ### 生成与发布约定 diff --git a/packages/typert/protocol/README.i18n.yaml b/packages/typert/protocol/README.i18n.yaml index fc20a25c24..e6e57a38e5 100644 --- a/packages/typert/protocol/README.i18n.yaml +++ b/packages/typert/protocol/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/typert/protocol/README.md -README.md: e41fa9fa305d8f55ce55d3eace647ad5afcea0e3 -README.zh.md: e784e771016ebec0913e0ea847258fe653ae3bd0 +README.md: f69c02a0a0e4b416c5749844f29a11ac0f5f9078 +README.zh.md: 588812fbd0afb8d420c67a838935edb5817d4137 diff --git a/packages/typert/protocol/README.md b/packages/typert/protocol/README.md index e41fa9fa30..f69c02a0a0 100644 --- a/packages/typert/protocol/README.md +++ b/packages/typert/protocol/README.md @@ -44,6 +44,8 @@ export class GoalService extends TypertRemoteService { Generation turns the method into a wire endpoint under the service's namespace; Clients call it as a typed method through `ctx.remote` (see the [API Gateway reference](../../../docs/api-gateway.md)). A method opts into cooperative cancellation by declaring `signal: AbortSignal` as its final parameter — the signal is injected, never a JSON parameter or lookup field. +A stream method (`@Remote({ mode: 'stream' })`) returns `Iterable`, `AsyncIterable`, or `RemoteStream`. `In` declares the items the Client may send back on the same logical stream; the method reads them through `this.ctx.invocation.uplink()`, and the descriptor carries their codec. `RemoteInvocation` also names the receiving `service`, the calling `peer` (a `PeerScope` the connection layer admitted), and the carrier `signal`; `ctx.invocation` is `undefined` on a Context no Remote call derived. On the Client face, `@deepseek-ai/dsh-typert-protocol/client` exports `RemoteStreamHandle` and resolves `RemoteStream` to it: the handle a generated stream method returns, with `send`, `end`, and `dispose` beside the downlink iteration. + ### Associating Host objects and Contexts with wire identities Complex Host objects cannot cross the wire directly. A business package declares the association through the merge-extensible `TypertLookupMap` and `TypertContextMap`. A Host Context adapter owns the stable wire declaration and resolves wire identities to live Contexts. A Client Context adapter maps in both directions because scoped calls originate from a Client Context and forwarded Host events resolve their explicit wire identity there. Host composition may override its synchronous or asynchronous resolver. A resolver that refuses on policy grounds throws `RemoteError` with its own code, which reaches the caller unchanged. @@ -89,7 +91,7 @@ The package keeps strict reflection in the compiler: decorator initializers reta ### Protocol maps and descriptors -The merge-extensible protocol maps keep static associations in the type system, while runtime providers register resolution with `ctx.typert`; the map names and shapes live in [`src/types.ts`](src/types.ts). `InvocationDescriptor` is the shared runtime form consumed by the registry, the Gateway, and the Client Remote, covering direct and Context receivers, JSON and lookup parameters, scope projections, cancellation, and result codecs. +The merge-extensible protocol maps keep static associations in the type system, while runtime providers register resolution with `ctx.typert`; the map names and shapes live in [`src/types.ts`](src/types.ts). `InvocationDescriptor` is the shared runtime form consumed by the registry, the Gateway, and the Client Remote, covering direct and Context receivers, JSON and lookup parameters, scope projections, the uplink codec, cancellation, and result codecs. ### Wire identity grammar @@ -101,7 +103,8 @@ Every namespace, method, lookup, and Context segment must satisfy `isTypertRemot |---|---| | [`src/index.ts`](src/index.ts) | Decorators, Gateway bindings, `remoteMethods`, segment validation | | [`src/remote-error.ts`](src/remote-error.ts) | `RemoteError` and the structural `remoteErrorOf` recognizer | -| [`src/types.ts`](src/types.ts) | Protocol maps, `RemoteErrorDetailsMap`, `RemoteResult`, `InvocationDescriptor`, codecs, provider contracts, registry interfaces, `TypertClientRemote` | +| [`src/types.ts`](src/types.ts) | Protocol maps, `RemoteErrorDetailsMap`, `RemoteResult`, `RemoteStream`, `PeerScope`, `RemoteInvocation`, `InvocationDescriptor`, codecs, provider contracts, registry interfaces, `TypertClientRemote` | +| [`src/client/index.ts`](src/client/index.ts) | Client face: `RemoteStreamHandle`, the Client `RemoteStream` alias, and every Host export | | — | No runtime invariant companion is published; decorators retain private immutable declarations and bindings are frozen values with no independent event stream to cross-check. | diff --git a/packages/typert/protocol/README.zh.md b/packages/typert/protocol/README.zh.md index e784e77101..588812fbd0 100644 --- a/packages/typert/protocol/README.zh.md +++ b/packages/typert/protocol/README.zh.md @@ -44,6 +44,8 @@ export class GoalService extends TypertRemoteService { 生成会把方法变为服务命名空间下的 wire 端点;Client 通过 `ctx.remote` 以类型化方法调用它(见 [API Gateway 参考](../../../docs/api-gateway.zh.md))。方法把 `signal: AbortSignal` 声明为最后一个参数即可选择协作式取消——该信号是注入的,绝不会成为 JSON 参数或查找字段。 +流方法(`@Remote({ mode: 'stream' })`)返回 `Iterable`、`AsyncIterable` 或 `RemoteStream`。`In` 声明 Client 可以在同一条逻辑流上回送的项;方法通过 `this.ctx.invocation.uplink()` 读取它们,描述符携带其 codec。`RemoteInvocation` 还给出接收服务 `service`、发起调用的 `peer`(连接层接纳的一个 `PeerScope`)与载体 `signal`;非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`。在 Client 面,`@deepseek-ai/dsh-typert-protocol/client` 导出 `RemoteStreamHandle` 并把 `RemoteStream` 解析为它:生成的流方法返回的句柄,在下行迭代之外提供 `send`、`end` 与 `dispose`。 + ### 把 Host 对象与 Context 关联到 wire identity 复杂的 Host 对象不能直接跨 wire 传输。业务包通过可合并扩展的 `TypertLookupMap` 与 `TypertContextMap` 声明关联。Host Context 适配器拥有稳定 wire 声明,并把 wire identity 解析为活跃 Context。Client Context 适配器需要双向映射,因为作用域调用从 Client Context 发起,而转发的 Host 事件要在 Client 侧解析其显式 wire identity。Host 组合可以覆盖其同步或异步解析器。因策略原因拒绝解析的解析器会抛出带有自身错误码的 `RemoteError`,该码原样到达调用方。 @@ -89,7 +91,7 @@ Host 装配以转发给消费方的 Cordis 事件扩展 `TypertRemoteEventSelect ### 协议映射与描述符 -可合并扩展的协议映射在类型系统中保留静态关联,运行时提供方则向 `ctx.typert` 注册解析;映射的名称与形状见 [`src/types.ts`](src/types.ts)。`InvocationDescriptor` 是注册表、Gateway 与 Client Remote 共同消费的共享运行时形式,涵盖直接与 Context 接收者、JSON 与查找参数、作用域投影、取消与结果编解码器。 +可合并扩展的协议映射在类型系统中保留静态关联,运行时提供方则向 `ctx.typert` 注册解析;映射的名称与形状见 [`src/types.ts`](src/types.ts)。`InvocationDescriptor` 是注册表、Gateway 与 Client Remote 共同消费的共享运行时形式,涵盖直接与 Context 接收者、JSON 与查找参数、作用域投影、上行编解码器、取消与结果编解码器。 ### Wire 标识文法 @@ -101,7 +103,8 @@ Host 装配以转发给消费方的 Cordis 事件扩展 `TypertRemoteEventSelect |---|---| | [`src/index.ts`](src/index.ts) | 装饰器、Gateway 绑定、`remoteMethods`、段校验 | | [`src/remote-error.ts`](src/remote-error.ts) | `RemoteError` 与结构式识别函数 `remoteErrorOf` | -| [`src/types.ts`](src/types.ts) | 协议映射、`RemoteErrorDetailsMap`、`RemoteResult`、`InvocationDescriptor`、编解码器、提供方约定、注册表接口、`TypertClientRemote` | +| [`src/types.ts`](src/types.ts) | 协议映射、`RemoteErrorDetailsMap`、`RemoteResult`、`RemoteStream`、`PeerScope`、`RemoteInvocation`、`InvocationDescriptor`、编解码器、提供方约定、注册表接口、`TypertClientRemote` | +| [`src/client/index.ts`](src/client/index.ts) | Client 面:`RemoteStreamHandle`、Client 侧的 `RemoteStream` 别名,以及全部 Host 导出 | | — | 不发布运行时不变量伴生入口;decorator 只保留私有不可变声明,binding 也是冻结值,没有可供交叉核对的独立事件流。 | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 061dd9f5f9..b6e9d6331f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2377,12 +2377,6 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials - '@deepseek-ai/dsh-scope': - specifier: workspace:^ - version: link:../../core/scope - '@deepseek-ai/dsh-typert-protocol': - specifier: workspace:^ - version: link:../../typert/protocol '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -2405,9 +2399,15 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol packages/client/file-upload: dependencies: diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index c3a8c6a239..fa9406d94e 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -157,6 +157,7 @@ export const SERVICE_PAGE: Record = { * to a model as `cordis_runtime_inspect what:"client"`). */ export const SERVICE_WALK_EXEMPTIONS: Record = { + invocation: 'not a service: per-call accessor (RemoteInvocation | undefined) the Gateway derives for each Remote call — packages/api/gateway/README.md owns the contract', webTerminals: 'client-side terminal view models — packages/api/terminal-controller/README.md owns the API', appReady: 'not a service: launcher-provided successful-startup signal — packages/boot/cmdline/README.md owns the launcher contract', appExit: 'not a service: launcher-provided bounded process-exit callback — packages/boot/cmdline/README.md owns the launcher contract', @@ -751,6 +752,9 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { ConnectionFetchHandler: 'shared Fetch dispatch is owned by packages/client/connection/src/rpc.ts', ConnectionRequestRejection: 'transport rejection status is owned by packages/client/connection/src/rpc.ts', ConnectionTrustRequest: 'transport authentication input is owned by packages/client/connection/src/rpc.ts', + PeerAdmission: 'Peer admission outcome is owned by packages/client/connection/src/rpc.ts', + PeerRegistryHandle: 'Peer registry contract is owned by packages/client/connection/src/rpc.ts', + PeerScope: 'Peer scope contract is owned by packages/typert/protocol/src/types.ts', ConnectionIndexRequest: 'frontend authentication request is owned by packages/client/connection/src/rpc.ts', ConnectionIndexResponse: 'frontend authentication response is owned by packages/client/connection/src/rpc.ts', Profile: 'resolved profile layers are owned by packages/boot/app-boot/README.md', diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts index 98b3bbb051..9510a65869 100644 --- a/scripts/package-dependency-policy.ts +++ b/scripts/package-dependency-policy.ts @@ -56,7 +56,7 @@ const SAFE_HOST_DEPENDENCY_EXPORTS = { /** Runtime exports that require every consumer to resolve the provider's shared peer instance. */ const PEER_REQUIRED_HOST_EXPORTS = { '@deepseek-ai/dsh-subprocess': ['SubprocessExecutableNotFoundError'], - '@deepseek-ai/dsh-scope': ['carrierKeyOf', 'scopeOf', 'scopeTarget'], + '@deepseek-ai/dsh-scope': ['carrierKeyOf', 'createScope', 'scopeOf', 'scopeTarget'], '@deepseek-ai/dsh-session': ['SESSION_FORMAT_VERSION'], '@deepseek-ai/dsh-session-persistence': ['SessionPersistenceNotFoundError'], } as const satisfies HostDependencyExports diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 5e4807e188..ffa2c30cc9 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1916,6 +1916,21 @@ "symbol": "InvocationDescriptor", "source": "packages/typert/protocol/src/types.ts" }, + { + "doc": "docs/subsystems/typert.md", + "symbol": "RemoteStream", + "source": "packages/typert/protocol/src/types.ts" + }, + { + "doc": "docs/subsystems/typert.md", + "symbol": "PeerScope", + "source": "packages/typert/protocol/src/types.ts" + }, + { + "doc": "docs/subsystems/typert.md", + "symbol": "RemoteInvocation", + "source": "packages/typert/protocol/src/types.ts" + }, { "doc": "docs/subsystems/typert.md", "symbol": "TypertRegistryContract", From ebdc1609c6242dc161324a552ef8ce8608d9627c Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 20 Sep 2026 11:32:06 +0800 Subject: [PATCH 19/60] docs(agents): mark the duplex note's pseudo-code fences as text --- .../2026-09-19-remote-duplex-stream.zh.md | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.agents/notes/proposed/architecture/2026-09-19-remote-duplex-stream.zh.md b/.agents/notes/proposed/architecture/2026-09-19-remote-duplex-stream.zh.md index 89823f6f5d..d5f1005714 100644 --- a/.agents/notes/proposed/architecture/2026-09-19-remote-duplex-stream.zh.md +++ b/.agents/notes/proposed/architecture/2026-09-19-remote-duplex-stream.zh.md @@ -93,7 +93,7 @@ Host 方法也没有"这次调用是谁发起的"这个概念:`InvokeRemoteReq ### 类型签名 -```ts +```text // @deepseek-ai/dsh-typert-protocol /** * 一条 Remote 流。Host 面:方法返回它,运行时就是 AsyncIterable。 @@ -125,7 +125,7 @@ stream.dispose() ### Host 面 -```ts +```text /** 本次 Remote 调用的上下文。方法通过 this.ctx.invocation 读它。 */ export interface RemoteInvocation { readonly request: { @@ -173,7 +173,7 @@ declare module '@deepseek-ai/cordis' { ### PeerScope -```ts +```text /** 一个 Peer 的不透明身份。 */ export type PeerId = Branded<'PeerId'> @@ -200,7 +200,7 @@ export interface PeerScope { ### Client 面 -```ts +```text /** Client 面上生成方法返回的句柄;RemoteStream 在 Client 面解析为它。 */ export interface RemoteStreamHandle extends AsyncIterable { /** 发送一个上行项。流已终止或已 end 时抛错。 */ @@ -219,7 +219,7 @@ export interface RemoteStreamHandle extends AsyncIterable { ### 线路帧 -```ts +```text export type RemoteStreamClientMessage = | { readonly type: 'open'; readonly streamId: string; readonly endpoint: string; readonly payload: unknown } | { readonly type: 'item'; readonly streamId: string; readonly value?: unknown } // 新增:上行项 @@ -314,7 +314,7 @@ credit 帧留待需要持续大流量上行的消费者出现时再加;inbox ### typert protocol(`packages/typert/protocol/src`) -```ts +```text // types.ts export type RemoteStream = AsyncIterable export type PeerId = Branded<'PeerId'> @@ -347,7 +347,7 @@ export interface InvocationDescriptor { ### 网关 Host(`packages/api/gateway/src`) -```ts +```text // types.ts export interface InvokeRemoteRequest { readonly namespace: string @@ -381,7 +381,7 @@ readonly streamInboxBytes?: number `prepareInvocation`: -```ts +```text const args = await Promise.all(descriptor.parameters.map(parameter => this.resolveParameter(parameter, request.args, endpoint))) const signal = request.signal ?? NEVER_ABORTED_SIGNAL const invocation = new GatewayInvocation( @@ -399,7 +399,7 @@ if (descriptor.cancellation !== undefined) args.push(signal) ### 网关 Host mux(`stream-server.ts`) -```ts +```text interface ActiveStream { readonly control: AbortController; readonly inbox: UplinkInbox; done: Promise } /** 有界上行队列;作为 uplink() 的源被迭代。 */ @@ -525,7 +525,7 @@ Client 调用方 mux client mux server 验收用,放在网关测试支持里: -```ts +```text class EchoService extends TypertRemoteService { constructor(ctx: Context) { super(ctx, 'echo', { namespace: 'echo' }) } From 77f1a493aa664051ec8a6cb2d93f0147b08ec9ba Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 20 Sep 2026 11:36:50 +0800 Subject: [PATCH 20/60] refactor(typert): keep the Remote marker mode as the 'stream' literal --- packages/typert/protocol/src/index.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/typert/protocol/src/index.ts b/packages/typert/protocol/src/index.ts index 9910ec2469..a677ede21b 100644 --- a/packages/typert/protocol/src/index.ts +++ b/packages/typert/protocol/src/index.ts @@ -98,7 +98,7 @@ export interface RemoteMethodMarker { /** Endpoint method when it differs from the implementation member. */ readonly exportName?: string /** Stream methods yield many independently validated result items. */ - readonly mode?: RemoteMethodOptions['mode'] + readonly mode?: 'stream' readonly invocation: RemoteInvocationMarker } @@ -122,7 +122,7 @@ interface RemoteInitializerContext { interface StoredRemoteMethodMarker { readonly exportName?: string - readonly mode?: RemoteMethodOptions['mode'] + readonly mode?: 'stream' readonly invocation: RemoteInvocationMarker } @@ -212,7 +212,7 @@ function remoteOptionMode(options: object): unknown { function remoteDecorator( invocation: RemoteInvocationMarker, - mode?: RemoteMethodOptions['mode'], + mode?: 'stream', exportName?: string, ): RemoteMethodDecorator { return function ( @@ -271,7 +271,7 @@ function readRemoteMethodDescriptor(prototype: object): RemoteMethodDescriptorV1 function addMarkerInitializer( context: RemoteInitializerContext, invocation: RemoteInvocationMarker, - mode?: RemoteMethodOptions['mode'], + mode?: 'stream', exportName?: string, ): void { if (context.private || context.static || typeof context.name !== 'string') { @@ -291,7 +291,7 @@ function mark( prototype: object, method: string, invocation: RemoteInvocationMarker, - mode?: RemoteMethodOptions['mode'], + mode?: 'stream', exportName?: string, ): void { const descriptor = readRemoteMethodDescriptor(prototype) From 35602063d73f68a1022e60fa3863a4aa0df1b341 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 20 Sep 2026 12:39:34 +0800 Subject: [PATCH 21/60] refactor(typert): name the client stream handle RemoteStreamHandle everywhere --- docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 2 +- docs/api-gateway.zh.md | 2 +- docs/subsystems/typert.i18n.yaml | 4 +- docs/subsystems/typert.md | 34 ++++++++++++-- docs/subsystems/typert.zh.md | 34 ++++++++++++-- packages/api/gateway/README.i18n.yaml | 4 +- packages/api/gateway/README.md | 2 +- packages/api/gateway/README.zh.md | 2 +- packages/api/gateway/src/client/index.ts | 2 +- .../api/gateway/tests/gateway.client.spec.ts | 2 +- .../tests/transport.client.spec.ts | 3 +- .../tests/model.client.spec.ts | 3 +- .../tests/fake-remote.client.ts | 3 +- .../test-support/remote-mock/src/streams.ts | 2 +- .../remote-mock/tests/streams.client.spec.ts | 2 +- packages/typert/generator/README.i18n.yaml | 4 +- packages/typert/generator/README.md | 2 +- packages/typert/generator/README.zh.md | 2 +- packages/typert/generator/src/emitter.ts | 12 ++--- .../fixtures/remote-model/tsconfig.base.json | 1 - .../remote-model/typert-protocol.d.ts | 24 +++------- .../generator/tests/remote-model.spec.ts | 5 +-- packages/typert/protocol/README.i18n.yaml | 4 +- packages/typert/protocol/README.md | 5 +-- packages/typert/protocol/README.zh.md | 5 +-- packages/typert/protocol/package.json | 4 -- packages/typert/protocol/src/client/index.ts | 45 ------------------- packages/typert/protocol/src/index.ts | 1 + packages/typert/protocol/src/types.ts | 32 +++++++++++-- scripts/type-equiv.manifest.json | 5 +++ tsconfig.base.json | 1 - 32 files changed, 135 insertions(+), 122 deletions(-) delete mode 100644 packages/typert/protocol/src/client/index.ts diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 412d04555a..4515aa50dd 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.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/api-gateway.md -api-gateway.md: ba67476f34e124bf6cd7e2874d0a200934644b93 -api-gateway.zh.md: cdc9443b37662428a1d1ba0730cb7b558b6ca2ab +api-gateway.md: 490a9fea0d4057d1e29ac37ad8693dedce1b6e16 +api-gateway.zh.md: 63f13140602f50c04e7aaa541cb4e0662c3c278d diff --git a/docs/api-gateway.md b/docs/api-gateway.md index ba67476f34..490a9fea0d 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -55,7 +55,7 @@ export class GoalService extends TypertRemoteService { Remote methods may return a value synchronously or return a Promise. For cooperative cancellation, the final parameter in the Host signature must be `signal: AbortSignal` using the global type; it is recorded in the descriptor instead of entering `args`, while the generated Client method accepts an optional final `AbortSignal`. -`@Remote({ mode: 'stream' })` marks a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream`: the Gateway validates and delivers each yielded item over its multiplexed `/api/remote.mux` WebSocket or an in-process carrier, and the generated Client method returns a stream handle — `RemoteStreamHandle`, the Client-face `RemoteStream` — that iterates the items and exposes `send`, `end`, and `dispose` for the Client-to-Host uplink of the same logical stream. The second type argument of `RemoteStream` declares the uplink item type; the Gateway validates each item the Client sends with the generated `In` codec before the Host method reads it through `this.ctx.invocation.uplink()`. The uplink enters neither `args` nor the parameter list; the [Gateway README](../packages/api/gateway/README.md) owns the frame, half-close, cancellation, and inbox-bound contracts. +`@Remote({ mode: 'stream' })` marks a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream`: the Gateway validates and delivers each yielded item over its multiplexed `/api/remote.mux` WebSocket or an in-process carrier, and the generated Client method returns a `RemoteStreamHandle` that iterates the items and exposes `send`, `end`, and `dispose` for the Client-to-Host uplink of the same logical stream. The second type argument of `RemoteStream` declares the uplink item type; the Gateway validates each item the Client sends with the generated `In` codec before the Host method reads it through `this.ctx.invocation.uplink()`. The uplink enters neither `args` nor the parameter list; the [Gateway README](../packages/api/gateway/README.md) owns the frame, half-close, cancellation, and inbox-bound contracts. The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. Direct and scoped calls appear under `ctx.remote.` and `agentCtx.remote.`. Each namespace is a traced Cordis child Service registered as `remote.`; the Client assembly mounts contributions through `ctx.remote.$mount()`, and the namespace unloads after its last method is withdrawn. Dependency declarations belong to the actual caller: only a business package that reads `ctx.remote.` or `agentCtx.remote.` declares both `remote` and `remote.` in its own `inject`; assemblies that only mount contributions and higher-level runtimes that do not call that namespace do not declare the namespace dependency on the business package's behalf. When an `@Remote` method has exactly one lookup parameter and a same-named `TypertContextMap` uses the same wire identity, the generated scoped signature omits that identity parameter. `@RemoteScope` generates only the scoped invocation interface. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index cdc9443b37..63f1314060 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -55,7 +55,7 @@ export class GoalService extends TypertRemoteService { Remote 方法可以同步返回或返回 Promise。若需要协作式取消,Host 签名的最后一个参数必须是全局类型的 `signal: AbortSignal`;它记录在描述符中而不是进入 `args`,Client 生成的方法则接受最后一个可选的 `AbortSignal`。 -`@Remote({ mode: 'stream' })` 标记返回 `Iterable`、`AsyncIterable` 或 `RemoteStream` 的方法:Gateway 校验每个产出的项,并经其多路复用的 `/api/remote.mux` WebSocket 或进程内载体投递,Client 生成的方法返回一个流句柄——`RemoteStreamHandle`,即 Client 面的 `RemoteStream`——它迭代这些项,并为同一条逻辑流上从 Client 到 Host 的上行提供 `send`、`end` 与 `dispose`。`RemoteStream` 的第二个类型参数声明上行项类型;Gateway 在 Host 方法通过 `this.ctx.invocation.uplink()` 读取之前,用生成的 `In` codec 校验 Client 发送的每一项。上行既不进入 `args` 也不进入参数列表;帧、半关闭、取消与 inbox 上限的约定由 [Gateway README](../packages/api/gateway/README.zh.md) 拥有。 +`@Remote({ mode: 'stream' })` 标记返回 `Iterable`、`AsyncIterable` 或 `RemoteStream` 的方法:Gateway 校验每个产出的项,并经其多路复用的 `/api/remote.mux` WebSocket 或进程内载体投递,Client 生成的方法返回一个 `RemoteStreamHandle`,它迭代这些项,并为同一条逻辑流上从 Client 到 Host 的上行提供 `send`、`end` 与 `dispose`。`RemoteStream` 的第二个类型参数声明上行项类型;Gateway 在 Host 方法通过 `this.ctx.invocation.uplink()` 读取之前,用生成的 `In` codec 校验 Client 发送的每一项。上行既不进入 `args` 也不进入参数列表;帧、半关闭、取消与 inbox 上限的约定由 [Gateway README](../packages/api/gateway/README.zh.md) 拥有。 Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接调用与作用域调用分别出现在 `ctx.remote.` 和 `agentCtx.remote.`。每个 namespace 都是注册为 `remote.` 的可追踪 Cordis 子服务;Client assembly 通过 `ctx.remote.$mount()` 挂载贡献,最后一个方法撤回后该 namespace 随即卸载。依赖声明归实际调用方所有:只有读取 `ctx.remote.` 或 `agentCtx.remote.` 的业务包才在自己的 `inject` 中同时声明 `remote` 与 `remote.`;只负责挂载 contribution 的 assembly,以及不调用该 namespace 的上层运行时,不代业务包声明 namespace 依赖。当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypertContextMap` 使用相同 wire identity 时,生成的作用域签名会省略该 identity 参数。`@RemoteScope` 只生成作用域调用接口。 diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index 1cf84e38da..0c750fc718 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/typert.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/typert.md -typert.md: 88e69f4ae264cc720efbfb8154f13a84d39160f8 -typert.zh.md: 306c1f4f46f3cb9b2816bbfcbdca5b8bc9fc2b76 +typert.md: 74ac49ce1875deef582a464d50bc581b1791aee7 +typert.zh.md: 5ff7bd82b12797ad64fbdefaae30730a4ef16ced diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index 88e69f4ae2..74ac49ce18 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -136,15 +136,43 @@ The Host alias `RemoteStream` names both directions of one stream, and * items the Client may send back on the same logical stream, read through * `RemoteInvocation.uplink()`; it is carried only as a type-level marker. The * default `never` declares a method that reads none, and its descriptor - * carries no uplink codec. On the Client face the same alias, exported by - * `@deepseek-ai/dsh-typert-protocol/client`, is the stream handle a generated - * method returns as `RemoteStreamHandle`. + * carries no uplink codec. A generated Client stream method returns the same + * stream as a `RemoteStreamHandle`. * @template Out - item type the Host method yields. * @template In - item type the Client may send; `never` when the method reads none. */ type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } ``` +```ts type-equiv +/** + * One open Remote stream as the Client holds it: the downlink items as an + * `AsyncIterable`, plus the uplink and cancellation of the same logical + * stream. A generated Client stream method returns it. A handle stands for + * one generation: when the carrier is lost, iteration fails with the carrier + * error and the handle is finished. + * @template Out - item type the Host method yields. + * @template In - item type the Client may send; `never` when the method reads none. + */ +interface RemoteStreamHandle extends AsyncIterable { + /** + * Send one uplink item. Items sent before the stream has opened are queued + * and sent once the `open` frame is on the wire. + * @param item - item the Host validates against the method's uplink codec. + * @throws {Error} when `end()` was called or the stream has terminated. + */ + send(item: In): void + /** Half-close the uplink: the Host's `uplink()` iteration ends. Idempotent; ignored after termination. */ + end(): void + /** + * Cancel the logical stream: send `cancel` unless a terminal frame has + * arrived, and end the downlink iterator quietly. Breaking out of + * `for await` early does the same. + */ + dispose(): void +} +``` + ```ts type-equiv /** * One Peer's session on this Host. Opened and disposed by whoever admitted the diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index 306c1f4f46..5ff7bd82b1 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -136,15 +136,43 @@ Host 别名 `RemoteStream` 同时命名一条流的两个方向,接 * items the Client may send back on the same logical stream, read through * `RemoteInvocation.uplink()`; it is carried only as a type-level marker. The * default `never` declares a method that reads none, and its descriptor - * carries no uplink codec. On the Client face the same alias, exported by - * `@deepseek-ai/dsh-typert-protocol/client`, is the stream handle a generated - * method returns as `RemoteStreamHandle`. + * carries no uplink codec. A generated Client stream method returns the same + * stream as a `RemoteStreamHandle`. * @template Out - item type the Host method yields. * @template In - item type the Client may send; `never` when the method reads none. */ type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } ``` +```ts type-equiv +/** + * One open Remote stream as the Client holds it: the downlink items as an + * `AsyncIterable`, plus the uplink and cancellation of the same logical + * stream. A generated Client stream method returns it. A handle stands for + * one generation: when the carrier is lost, iteration fails with the carrier + * error and the handle is finished. + * @template Out - item type the Host method yields. + * @template In - item type the Client may send; `never` when the method reads none. + */ +interface RemoteStreamHandle extends AsyncIterable { + /** + * Send one uplink item. Items sent before the stream has opened are queued + * and sent once the `open` frame is on the wire. + * @param item - item the Host validates against the method's uplink codec. + * @throws {Error} when `end()` was called or the stream has terminated. + */ + send(item: In): void + /** Half-close the uplink: the Host's `uplink()` iteration ends. Idempotent; ignored after termination. */ + end(): void + /** + * Cancel the logical stream: send `cancel` unless a terminal frame has + * arrived, and end the downlink iterator quietly. Breaking out of + * `for await` early does the same. + */ + dispose(): void +} +``` + ```ts type-equiv /** * One Peer's session on this Host. Opened and disposed by whoever admitted the diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 1803b6628a..b08ffcb3db 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/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/api/gateway/README.md -README.md: 9cf250a0578789557c02b88e2ec0c34620196644 -README.zh.md: 757b7bb45a6828e86fba31c06088020302344512 +README.md: 6275cc09b2a27a6fb635ea506cc52c54e459e920 +README.zh.md: b9078fff9aeed3a2416b435c1bb53cba13ed353f diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 9cf250a057..6275cc09b2 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -45,7 +45,7 @@ The browser carrier accepts the shell-owned stream origin described by [Connecti `ctx.remote.$mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Each namespace is a traced `remote.` child Service and unloads after its last method is withdrawn. Duplicate endpoints, namespace collisions, and Client-supplied fields without strict generated codecs fail before methods become callable. -Each unary call checks positional arity, constructs the descriptor's exact named `args`, and sends the typed values through `ctx.connection.rpc.call('/api', endpoint, ...)` without executing Client-side schemas; the Host validates the received wire fields before business invocation. A generated stream method returns a `RemoteStreamHandle` — the `RemoteStream` alias of `@deepseek-ai/dsh-typert-protocol/client` — and opens one logical stream when it is called, through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. The handle iterates the downlink once. `send(item)` queues an uplink item, sent after the `open` frame; `end()` half-closes the uplink; `dispose()` sends `cancel` unless a terminal frame has arrived and ends the iteration quietly. Breaking out of the iteration early disposes the handle; after the downlink terminates `send` throws and `end` is ignored. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before invoking the carrier. Successful unary results and stream items pass through without Client-side type parsing. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject. +Each unary call checks positional arity, constructs the descriptor's exact named `args`, and sends the typed values through `ctx.connection.rpc.call('/api', endpoint, ...)` without executing Client-side schemas; the Host validates the received wire fields before business invocation. A generated stream method returns a `RemoteStreamHandle` from `dsh-typert-protocol` and opens one logical stream when it is called, through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. The handle iterates the downlink once. `send(item)` queues an uplink item, sent after the `open` frame; `end()` half-closes the uplink; `dispose()` sends `cancel` unless a terminal frame has arrived and ends the iteration quietly. Breaking out of the iteration early disposes the handle; after the downlink terminates `send` throws and `end` is ignored. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before invoking the carrier. Successful unary results and stream items pass through without Client-side type parsing. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject. Every unary call resolves to `RemoteResult` — `{ ok: true, value }` or `{ ok: false, error }` — and never rejects for a carrier problem: this face folds an offline carrier into the error branch and answers `gateway/cancelled` when the caller's signal aborts, so no consumer wraps a call to recover one. Only an assembly fault still rejects: wrong arity, an unmounted method, a withdrawn contribution, a missing Context adapter. `error` is a live `RemoteError` instance, so `throw result.error` keeps throw semantics, and `isRemoteFailure(value)` is the one predicate a consumer needs — a caught value it accepts carries a Host code, and anything it rejects is a local fault the caller should let crash. `carrierFailure(endpoint, error)` and `cancelledFailure(endpoint, cause)` build those two folded results, so a test stand-in for this face folds identically. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 757b7bb45a..b9078fff9a 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -45,7 +45,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source `ctx.remote.$mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。每个 namespace 都是可追踪的 `remote.` 子 Service,并在最后一个方法撤回后卸载。重复端点、命名空间冲突,以及缺少生成的严格 codec 的 Client 供值字段,都会在方法可调用前报错。 -每次一元调用都会检查位置参数数量,构造与描述符完全匹配的具名 `args`,再把带类型的值原样交给 `ctx.connection.rpc.call('/api', endpoint, ...)`,而不执行 Client 侧 schema;Host 会在业务调用前校验收到的 wire 字段。生成的流方法返回 `RemoteStreamHandle`——即 `@deepseek-ai/dsh-typert-protocol/client` 的 `RemoteStream` 别名——并在被调用时就打开一条逻辑流,进程内 Connection 载体可用时通过它打开,否则通过共享的 Gateway WebSocket 打开。句柄只迭代下行一次。`send(item)` 把一个上行项入队,在 `open` 帧之后发出;`end()` 半关闭上行;`dispose()` 在未收到终止帧时发送 `cancel`,并让迭代安静结束。提前跳出迭代等价于 dispose 句柄;下行终止后 `send` 抛错,`end` 被忽略。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。成功的一元结果与流项不经 Client 侧类型解析直接传递。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。 +每次一元调用都会检查位置参数数量,构造与描述符完全匹配的具名 `args`,再把带类型的值原样交给 `ctx.connection.rpc.call('/api', endpoint, ...)`,而不执行 Client 侧 schema;Host 会在业务调用前校验收到的 wire 字段。生成的流方法返回 `dsh-typert-protocol` 的 `RemoteStreamHandle`,并在被调用时就打开一条逻辑流,进程内 Connection 载体可用时通过它打开,否则通过共享的 Gateway WebSocket 打开。句柄只迭代下行一次。`send(item)` 把一个上行项入队,在 `open` 帧之后发出;`end()` 半关闭上行;`dispose()` 在未收到终止帧时发送 `cancel`,并让迭代安静结束。提前跳出迭代等价于 dispose 句柄;下行终止后 `send` 抛错,`end` 被忽略。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。成功的一元结果与流项不经 Client 侧类型解析直接传递。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。 每次一元调用都解析为 `RemoteResult`——`{ ok: true, value }` 或 `{ ok: false, error }`——且绝不因载体问题 reject:本面把断线载体折入错误分支,调用方 signal 中止时答以 `gateway/cancelled`,因此没有消费方需要包一层来兜载体失败。只有装配故障仍会 reject:参数个数不符、方法未挂载、贡献已撤下、缺少 Context 适配器。`error` 是活的 `RemoteError` 实例,所以 `throw result.error` 保持 throw 语义;而 `isRemoteFailure(value)` 是消费方唯一需要的谓词——它认下的捕获值带着 Host 码,它拒绝的一律是本地故障,调用方应当让其崩掉。`carrierFailure(endpoint, error)` 与 `cancelledFailure(endpoint, cause)` 构造这两种折叠结果,测试里的替代实现据此采用相同的折叠方式。 diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index 1d44861ee8..161943ce6f 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -7,7 +7,6 @@ import { Service } from '@deepseek-ai/cordis' import { Deque } from '@deepseek-ai/dsh-deque' import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' -import type { RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol/client' export type { TypertGatewayFaultDetails } from '../remote-error-codes.ts' import type { Context } from '@deepseek-ai/cordis' import type { @@ -19,6 +18,7 @@ import type { TypertClientRemote, RemoteFailure, RemoteResult, + RemoteStreamHandle, TypertCodec, TypertDisposer, TypertRemoteContribution, diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index e0c7a2cfef..06c0e93639 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -14,6 +14,7 @@ import { import type { InvocationDescriptor, RemoteResult, + RemoteStreamHandle, TypertContextMap, TypertContextWire, TypertContext, @@ -21,7 +22,6 @@ import type { TypertRemoteScopeApi, TypertRemoteNamespace, } from '@deepseek-ai/dsh-typert-protocol' -import type { RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol/client' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { ClientRemote } from '../src/client/index.ts' import { apply, inject, RemoteStream } from '../src/client/index.ts' diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts index fb90a0dec1..45e6c2b039 100644 --- a/packages/api/session-controller/tests/transport.client.spec.ts +++ b/packages/api/session-controller/tests/transport.client.spec.ts @@ -5,11 +5,10 @@ import { type RemoteStreamOptions, } from '@deepseek-ai/dsh-api-gateway/client' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -import type { RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol/client' import { streamHandle } from '@deepseek-ai/dsh-remote-mock' import { LlmAttemptId } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session/types' -import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteResult, RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol' import { createSessionControlStream, SessionEventStream, diff --git a/packages/api/workspace-controller/tests/model.client.spec.ts b/packages/api/workspace-controller/tests/model.client.spec.ts index ad4ae864aa..35e659759a 100644 --- a/packages/api/workspace-controller/tests/model.client.spec.ts +++ b/packages/api/workspace-controller/tests/model.client.spec.ts @@ -22,8 +22,7 @@ import type { WorkspaceId, WorkspaceView, } from '../src/types.ts' -import { RemoteError, type RemoteFailure, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' -import type { RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol/client' +import { RemoteError, type RemoteFailure, type RemoteResult, type RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol' import { streamHandle } from '@deepseek-ai/dsh-remote-mock' import type { SessionId } from '@deepseek-ai/dsh-session/types' diff --git a/packages/api/workspace-files/tests/fake-remote.client.ts b/packages/api/workspace-files/tests/fake-remote.client.ts index f673145b90..ecb6aedf2b 100644 --- a/packages/api/workspace-files/tests/fake-remote.client.ts +++ b/packages/api/workspace-files/tests/fake-remote.client.ts @@ -3,9 +3,8 @@ * opened `changes` generation, and a supervisor that runs one generation and * classifies its end the way the real one does. */ -import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteResult, RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -import type { RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol/client' import { streamHandle } from '@deepseek-ai/dsh-remote-mock' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceFileWatchFrame, WorkspaceFileStat } from '../src/types.ts' diff --git a/packages/test-support/remote-mock/src/streams.ts b/packages/test-support/remote-mock/src/streams.ts index 0205e2ac8a..12aeb10a99 100644 --- a/packages/test-support/remote-mock/src/streams.ts +++ b/packages/test-support/remote-mock/src/streams.ts @@ -1,6 +1,6 @@ /** Stream scripts and the pushable, abort-aware stream a script drives. */ -import type { RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol/client' +import type { RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol' import type { StreamRecord } from './log.ts' /** diff --git a/packages/test-support/remote-mock/tests/streams.client.spec.ts b/packages/test-support/remote-mock/tests/streams.client.spec.ts index 5755e9fc1c..8dbdf2b018 100644 --- a/packages/test-support/remote-mock/tests/streams.client.spec.ts +++ b/packages/test-support/remote-mock/tests/streams.client.spec.ts @@ -1,6 +1,6 @@ /** Stream scripts, live stream control, cancellation, and the built-in `$events` opening. */ import { describe, expect, it } from 'vitest' -import type { RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol/client' +import type { RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol' import { RemoteMock, frames, openStream, streamHandle, streamMethod } from '../src/index.ts' const idle = (): AbortSignal => new AbortController().signal diff --git a/packages/typert/generator/README.i18n.yaml b/packages/typert/generator/README.i18n.yaml index e35624d914..c5841ecac6 100644 --- a/packages/typert/generator/README.i18n.yaml +++ b/packages/typert/generator/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/typert/generator/README.md -README.md: ad78a9108fe7b1f8178ff9866649633ed5072c60 -README.zh.md: c88b8bdd67e8c271817ff668ae4b62f7ea8b541d +README.md: aa7fd3d6e068ea6532d55de04970ab3e28b74285 +README.zh.md: e911feac821deda1d0876c26c575dcff5cd2a741 diff --git a/packages/typert/generator/README.md b/packages/typert/generator/README.md index ad78a9108f..aa7fd3d6e0 100644 --- a/packages/typert/generator/README.md +++ b/packages/typert/generator/README.md @@ -79,7 +79,7 @@ The generator is built on one separation: extraction and emission are decoupled ### Analysis and faces -Host and Client are independent TypeScript programs. Direct project references establish compiler-face membership, while `dsh.client` package subpaths establish runtime-face contribution; `package.json#exports` marks every cross-package public boundary, and imports or re-exports are the only cross-face edges. A relative import that resolves inside the referencing package is followed through that module's re-exports until a package specifier appears, so package-local forwarding modules keep their original declaration references; a relative import that resolves into another package fails. `check` mode fails on syntax or semantic diagnostics, missing public annotations, private cross-package references, and reachable declaration merges the model cannot retain losslessly; `write` mode inserts checker-derived annotations and returns a clean check-mode model. Types owned by NPM dependencies remain `external` references instead of being expanded. A stream method's return type may be `Iterable`, `AsyncIterable`, or the protocol's `RemoteStream`, recognized by symbol and declaring package like the standard-library wrappers; a second type argument other than `never` yields the descriptor's uplink codec, and the generated Client signature returns `RemoteStreamHandle` from the protocol's Client entry. +Host and Client are independent TypeScript programs. Direct project references establish compiler-face membership, while `dsh.client` package subpaths establish runtime-face contribution; `package.json#exports` marks every cross-package public boundary, and imports or re-exports are the only cross-face edges. A relative import that resolves inside the referencing package is followed through that module's re-exports until a package specifier appears, so package-local forwarding modules keep their original declaration references; a relative import that resolves into another package fails. `check` mode fails on syntax or semantic diagnostics, missing public annotations, private cross-package references, and reachable declaration merges the model cannot retain losslessly; `write` mode inserts checker-derived annotations and returns a clean check-mode model. Types owned by NPM dependencies remain `external` references instead of being expanded. A stream method's return type may be `Iterable`, `AsyncIterable`, or the protocol's `RemoteStream`, recognized by symbol and declaring package like the standard-library wrappers; a second type argument other than `never` yields the descriptor's uplink codec, and the generated Client signature returns `RemoteStreamHandle`. ### Emission and publication contract diff --git a/packages/typert/generator/README.zh.md b/packages/typert/generator/README.zh.md index c88b8bdd67..e911feac82 100644 --- a/packages/typert/generator/README.zh.md +++ b/packages/typert/generator/README.zh.md @@ -79,7 +79,7 @@ files: ### 分析与 face -Host 与 Client 是两个独立的 TypeScript 程序。直接项目引用确定编译器 face 的成员归属,`dsh.client` 包子路径则确定运行时 face 的贡献;`package.json#exports` 划定所有跨包公开边界,跨 face 的边只能来自导入或重新导出。解析到本包内部模块的相对导入会沿该模块的重新导出继续追踪,直到出现包说明符,因此包内转发模块保留原始声明引用;解析到其他包的相对导入会失败。`check` 模式遇到语法或语义诊断、缺失的公开类型标注、跨包私有引用,以及模型无法无损保留的可达声明合并时都会失败;`write` 模式插入类型检查器推导出的标注,并返回无诊断的 check 模式模型。NPM 依赖拥有的类型继续以 `external` 引用表示,不会被展开。流方法的返回类型可以是 `Iterable`、`AsyncIterable` 或协议包的 `RemoteStream`,后者与标准库包装器一样按符号名与声明所在包识别;第二个类型参数不为 `never` 时产出描述符的上行编解码器,生成的 Client 签名则返回来自协议包 Client 入口的 `RemoteStreamHandle`。 +Host 与 Client 是两个独立的 TypeScript 程序。直接项目引用确定编译器 face 的成员归属,`dsh.client` 包子路径则确定运行时 face 的贡献;`package.json#exports` 划定所有跨包公开边界,跨 face 的边只能来自导入或重新导出。解析到本包内部模块的相对导入会沿该模块的重新导出继续追踪,直到出现包说明符,因此包内转发模块保留原始声明引用;解析到其他包的相对导入会失败。`check` 模式遇到语法或语义诊断、缺失的公开类型标注、跨包私有引用,以及模型无法无损保留的可达声明合并时都会失败;`write` 模式插入类型检查器推导出的标注,并返回无诊断的 check 模式模型。NPM 依赖拥有的类型继续以 `external` 引用表示,不会被展开。流方法的返回类型可以是 `Iterable`、`AsyncIterable` 或协议包的 `RemoteStream`,后者与标准库包装器一样按符号名与声明所在包识别;第二个类型参数不为 `never` 时产出描述符的上行编解码器,生成的 Client 签名则返回 `RemoteStreamHandle`。 ### 生成与发布约定 diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index 830b0f9010..f7c1beaf71 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -23,9 +23,6 @@ import type { } from './model.ts' import { TypeGraphRenderer } from './renderer.ts' -/** Protocol entry that exports the handle a generated Client stream method returns. */ -const REMOTE_CLIENT_PROTOCOL = '@deepseek-ai/dsh-typert-protocol/client' - /** Failure to project a modeled construct into an emitted artifact. */ export class TypertEmitError extends Error { override name = 'TypertEmitError' @@ -352,18 +349,15 @@ export class FaceModelEmitter { }) grouped.set(imported.specifier, values) } - // A stream method's Client signature names the handle from the protocol's - // Client entry; the Host entry's `RemoteStream` alias would shadow a - // same-named import inside the augmentation below. + // A stream method's Client signature returns the handle; `RemoteStream` names + // what the Host method returns. const lines = [ '/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */', 'import type {', ' RemoteResult,', + ...packageModel.invocations.some(invocation => invocation.mode === 'stream') ? [' RemoteStreamHandle,'] : [], ' TypertRemoteContribution,', '} from \'@deepseek-ai/dsh-typert-protocol\'', - ...packageModel.invocations.some(invocation => invocation.mode === 'stream') - ? [`import type { RemoteStreamHandle } from ${quote(REMOTE_CLIENT_PROTOCOL)}`] - : [], ] const sourceMap = new GenMapping({ file: 'typert.remote-client.d.ts' }) for (const [specifier, values] of [...grouped].sort(([left], [right]) => left.localeCompare(right))) { diff --git a/packages/typert/generator/tests/fixtures/remote-model/tsconfig.base.json b/packages/typert/generator/tests/fixtures/remote-model/tsconfig.base.json index 06c2be3ea8..4561f6ef73 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/tsconfig.base.json +++ b/packages/typert/generator/tests/fixtures/remote-model/tsconfig.base.json @@ -10,7 +10,6 @@ "ignoreDeprecations": "6.0", "paths": { "@deepseek-ai/dsh-typert-protocol": ["./typert-protocol.d.ts"], - "@deepseek-ai/dsh-typert-protocol/client": ["./typert-protocol.d.ts"], "@fixture/domain": ["./packages/domain/src/index.ts"], "@fixture/domain/*": ["./packages/domain/src/*"], "@fixture/remote": ["./packages/remote/src/index.ts"], diff --git a/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts b/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts index 38e1676c20..5bd9db5755 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts @@ -38,6 +38,12 @@ declare module '@deepseek-ai/dsh-typert-protocol' { export type RemoteStream = AsyncIterable & { readonly uplinkItem?: In } + export interface RemoteStreamHandle extends AsyncIterable { + send(item: In): void + end(): void + dispose(): void + } + export abstract class TypertRemoteService { readonly typertRemote: { readonly service: TypertRemoteService @@ -74,21 +80,3 @@ declare module '@deepseek-ai/dsh-typert-protocol' { context: ClassMethodDecoratorContext Result>, ) => void } - -declare module '@deepseek-ai/dsh-typert-protocol/client' { - export interface RemoteStreamHandle extends AsyncIterable { - send(item: In): void - end(): void - dispose(): void - } - - export type RemoteStream = RemoteStreamHandle - - export type { - RemoteResult, - TypertRemoteContribution, - TypertRemoteMap, - TypertRemoteNamespaceMap, - TypertRemoteScopeMap, - } from '@deepseek-ai/dsh-typert-protocol' -} diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index 8dfd773b08..e291df692c 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -272,7 +272,7 @@ export type {`, const [artifact] = new WorkspaceTypertGenerator(root).generate() expect(artifact?.remote?.dts).toContain( - "import type { RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol/client'", + " RemoteStreamHandle,\n TypertRemoteContribution,\n} from '@deepseek-ai/dsh-typert-protocol'", ) expect(artifact?.remote?.dts).toContain("declare module '@deepseek-ai/dsh-typert-protocol' {") expect(artifact?.remote?.dts).toContain( @@ -755,12 +755,12 @@ function assertRemoteConsumerTypechecks( import remote from '@fixture/remote/remote' import type { RemoteResult, + RemoteStreamHandle, TypertRemoteContribution, TypertRemoteScopeMap, TypertRemoteMap, TypertRemoteNamespaceMap, } from '@deepseek-ai/dsh-typert-protocol' -import type { RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol/client' import type { CreateGoalResult, RenameGoalResult } from '@fixture/remote/types' const contribution: TypertRemoteContribution = remote @@ -792,7 +792,6 @@ void navigated skipLibCheck: false, paths: { '@deepseek-ai/dsh-typert-protocol': ['./typert-protocol.d.ts'], - '@deepseek-ai/dsh-typert-protocol/client': ['./typert-protocol.d.ts'], '@fixture/domain/types': ['./packages/domain/src/types.ts'], '@fixture/remote/types': ['./packages/remote/src/types.ts'], '@fixture/remote/remote': ['./packages/remote/lib/typert.remote-client.d.ts'], diff --git a/packages/typert/protocol/README.i18n.yaml b/packages/typert/protocol/README.i18n.yaml index e6e57a38e5..5b100b5921 100644 --- a/packages/typert/protocol/README.i18n.yaml +++ b/packages/typert/protocol/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/typert/protocol/README.md -README.md: f69c02a0a0e4b416c5749844f29a11ac0f5f9078 -README.zh.md: 588812fbd0afb8d420c67a838935edb5817d4137 +README.md: f3a758cc9f65ccfff7d5f15af801702359674fa3 +README.zh.md: 4630124ff623ca8223dcae326403c5504db52483 diff --git a/packages/typert/protocol/README.md b/packages/typert/protocol/README.md index f69c02a0a0..f3a758cc9f 100644 --- a/packages/typert/protocol/README.md +++ b/packages/typert/protocol/README.md @@ -44,7 +44,7 @@ export class GoalService extends TypertRemoteService { Generation turns the method into a wire endpoint under the service's namespace; Clients call it as a typed method through `ctx.remote` (see the [API Gateway reference](../../../docs/api-gateway.md)). A method opts into cooperative cancellation by declaring `signal: AbortSignal` as its final parameter — the signal is injected, never a JSON parameter or lookup field. -A stream method (`@Remote({ mode: 'stream' })`) returns `Iterable`, `AsyncIterable`, or `RemoteStream`. `In` declares the items the Client may send back on the same logical stream; the method reads them through `this.ctx.invocation.uplink()`, and the descriptor carries their codec. `RemoteInvocation` also names the receiving `service`, the calling `peer` (a `PeerScope` the connection layer admitted), and the carrier `signal`; `ctx.invocation` is `undefined` on a Context no Remote call derived. On the Client face, `@deepseek-ai/dsh-typert-protocol/client` exports `RemoteStreamHandle` and resolves `RemoteStream` to it: the handle a generated stream method returns, with `send`, `end`, and `dispose` beside the downlink iteration. +A stream method (`@Remote({ mode: 'stream' })`) returns `Iterable`, `AsyncIterable`, or `RemoteStream`. `In` declares the items the Client may send back on the same logical stream; the method reads them through `this.ctx.invocation.uplink()`, and the descriptor carries their codec. `RemoteInvocation` also names the receiving `service`, the calling `peer` (a `PeerScope` the connection layer admitted), and the carrier `signal`; `ctx.invocation` is `undefined` on a Context no Remote call derived. A generated Client stream method returns `RemoteStreamHandle`: the handle with `send`, `end`, and `dispose` beside the downlink iteration. ### Associating Host objects and Contexts with wire identities @@ -103,8 +103,7 @@ Every namespace, method, lookup, and Context segment must satisfy `isTypertRemot |---|---| | [`src/index.ts`](src/index.ts) | Decorators, Gateway bindings, `remoteMethods`, segment validation | | [`src/remote-error.ts`](src/remote-error.ts) | `RemoteError` and the structural `remoteErrorOf` recognizer | -| [`src/types.ts`](src/types.ts) | Protocol maps, `RemoteErrorDetailsMap`, `RemoteResult`, `RemoteStream`, `PeerScope`, `RemoteInvocation`, `InvocationDescriptor`, codecs, provider contracts, registry interfaces, `TypertClientRemote` | -| [`src/client/index.ts`](src/client/index.ts) | Client face: `RemoteStreamHandle`, the Client `RemoteStream` alias, and every Host export | +| [`src/types.ts`](src/types.ts) | Protocol maps, `RemoteErrorDetailsMap`, `RemoteResult`, `RemoteStream`, `RemoteStreamHandle`, `PeerScope`, `RemoteInvocation`, `InvocationDescriptor`, codecs, provider contracts, registry interfaces, `TypertClientRemote` | | — | No runtime invariant companion is published; decorators retain private immutable declarations and bindings are frozen values with no independent event stream to cross-check. | diff --git a/packages/typert/protocol/README.zh.md b/packages/typert/protocol/README.zh.md index 588812fbd0..4630124ff6 100644 --- a/packages/typert/protocol/README.zh.md +++ b/packages/typert/protocol/README.zh.md @@ -44,7 +44,7 @@ export class GoalService extends TypertRemoteService { 生成会把方法变为服务命名空间下的 wire 端点;Client 通过 `ctx.remote` 以类型化方法调用它(见 [API Gateway 参考](../../../docs/api-gateway.zh.md))。方法把 `signal: AbortSignal` 声明为最后一个参数即可选择协作式取消——该信号是注入的,绝不会成为 JSON 参数或查找字段。 -流方法(`@Remote({ mode: 'stream' })`)返回 `Iterable`、`AsyncIterable` 或 `RemoteStream`。`In` 声明 Client 可以在同一条逻辑流上回送的项;方法通过 `this.ctx.invocation.uplink()` 读取它们,描述符携带其 codec。`RemoteInvocation` 还给出接收服务 `service`、发起调用的 `peer`(连接层接纳的一个 `PeerScope`)与载体 `signal`;非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`。在 Client 面,`@deepseek-ai/dsh-typert-protocol/client` 导出 `RemoteStreamHandle` 并把 `RemoteStream` 解析为它:生成的流方法返回的句柄,在下行迭代之外提供 `send`、`end` 与 `dispose`。 +流方法(`@Remote({ mode: 'stream' })`)返回 `Iterable`、`AsyncIterable` 或 `RemoteStream`。`In` 声明 Client 可以在同一条逻辑流上回送的项;方法通过 `this.ctx.invocation.uplink()` 读取它们,描述符携带其 codec。`RemoteInvocation` 还给出接收服务 `service`、发起调用的 `peer`(连接层接纳的一个 `PeerScope`)与载体 `signal`;非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`。生成的 Client 流方法返回 `RemoteStreamHandle`:在下行迭代之外提供 `send`、`end` 与 `dispose` 的句柄。 ### 把 Host 对象与 Context 关联到 wire identity @@ -103,8 +103,7 @@ Host 装配以转发给消费方的 Cordis 事件扩展 `TypertRemoteEventSelect |---|---| | [`src/index.ts`](src/index.ts) | 装饰器、Gateway 绑定、`remoteMethods`、段校验 | | [`src/remote-error.ts`](src/remote-error.ts) | `RemoteError` 与结构式识别函数 `remoteErrorOf` | -| [`src/types.ts`](src/types.ts) | 协议映射、`RemoteErrorDetailsMap`、`RemoteResult`、`RemoteStream`、`PeerScope`、`RemoteInvocation`、`InvocationDescriptor`、编解码器、提供方约定、注册表接口、`TypertClientRemote` | -| [`src/client/index.ts`](src/client/index.ts) | Client 面:`RemoteStreamHandle`、Client 侧的 `RemoteStream` 别名,以及全部 Host 导出 | +| [`src/types.ts`](src/types.ts) | 协议映射、`RemoteErrorDetailsMap`、`RemoteResult`、`RemoteStream`、`RemoteStreamHandle`、`PeerScope`、`RemoteInvocation`、`InvocationDescriptor`、编解码器、提供方约定、注册表接口、`TypertClientRemote` | | — | 不发布运行时不变量伴生入口;decorator 只保留私有不可变声明,binding 也是冻结值,没有可供交叉核对的独立事件流。 | diff --git a/packages/typert/protocol/package.json b/packages/typert/protocol/package.json index 30a0570980..0b998c9897 100644 --- a/packages/typert/protocol/package.json +++ b/packages/typert/protocol/package.json @@ -18,10 +18,6 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./client": { - "types": "./lib/types/client/index.d.ts", - "default": "./lib/types/client/index.js" - }, "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" diff --git a/packages/typert/protocol/src/client/index.ts b/packages/typert/protocol/src/client/index.ts deleted file mode 100644 index 980f74b607..0000000000 --- a/packages/typert/protocol/src/client/index.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Client face of the Typert protocol: every Host export, with `RemoteStream` - * resolving to the handle a generated Client method returns. Generated - * Host-for-Client contracts import `RemoteStreamHandle` from this entry for - * their stream signatures, so Client code that names a stream as - * `RemoteStream` from this entry and the generated method agree, - * while the Host alias stays an `AsyncIterable`. - * @module @deepseek-ai/dsh-typert-protocol/client - */ - -/** - * One open Remote stream as the Client holds it: the downlink items as an - * `AsyncIterable`, plus the uplink and cancellation of the same logical - * stream. A handle stands for one generation: when the carrier is lost, - * iteration fails with the carrier error and the handle is finished. - * @template Out - item type the Host method yields. - * @template In - item type the Client may send; `never` when the method reads none. - */ -export interface RemoteStreamHandle extends AsyncIterable { - /** - * Send one uplink item. Items sent before the stream has opened are queued - * and sent once the `open` frame is on the wire. - * @param item - item the Host validates against the method's uplink codec. - * @throws {Error} when `end()` was called or the stream has terminated. - */ - send(item: In): void - /** Half-close the uplink: the Host's `uplink()` iteration ends. Idempotent; ignored after termination. */ - end(): void - /** - * Cancel the logical stream: send `cancel` unless a terminal frame has - * arrived, and end the downlink iterator quietly. Breaking out of - * `for await` early does the same. - */ - dispose(): void -} - -/** - * `RemoteStream` on the Client face: the handle a generated Remote - * method returns. - * @template Out - item type the Host method yields. - * @template In - item type the Client may send; `never` when the method reads none. - */ -export type RemoteStream = RemoteStreamHandle - -export * from '../index.ts' diff --git a/packages/typert/protocol/src/index.ts b/packages/typert/protocol/src/index.ts index a677ede21b..b9acc4888e 100644 --- a/packages/typert/protocol/src/index.ts +++ b/packages/typert/protocol/src/index.ts @@ -35,6 +35,7 @@ export type { RemoteInvocation, RemoteResult, RemoteStream, + RemoteStreamHandle, TypertClientEventListener, TypertClientRemote, TypertClientContextAdapter, diff --git a/packages/typert/protocol/src/types.ts b/packages/typert/protocol/src/types.ts index 4ec840592f..3e5062adcb 100644 --- a/packages/typert/protocol/src/types.ts +++ b/packages/typert/protocol/src/types.ts @@ -85,14 +85,40 @@ declare const STREAM_UPLINK: unique symbol * items the Client may send back on the same logical stream, read through * `RemoteInvocation.uplink()`; it is carried only as a type-level marker. The * default `never` declares a method that reads none, and its descriptor - * carries no uplink codec. On the Client face the same alias, exported by - * `@deepseek-ai/dsh-typert-protocol/client`, is the stream handle a generated - * method returns as `RemoteStreamHandle`. + * carries no uplink codec. A generated Client stream method returns the same + * stream as a `RemoteStreamHandle`. * @template Out - item type the Host method yields. * @template In - item type the Client may send; `never` when the method reads none. */ export type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } +/** + * One open Remote stream as the Client holds it: the downlink items as an + * `AsyncIterable`, plus the uplink and cancellation of the same logical + * stream. A generated Client stream method returns it. A handle stands for + * one generation: when the carrier is lost, iteration fails with the carrier + * error and the handle is finished. + * @template Out - item type the Host method yields. + * @template In - item type the Client may send; `never` when the method reads none. + */ +export interface RemoteStreamHandle extends AsyncIterable { + /** + * Send one uplink item. Items sent before the stream has opened are queued + * and sent once the `open` frame is on the wire. + * @param item - item the Host validates against the method's uplink codec. + * @throws {Error} when `end()` was called or the stream has terminated. + */ + send(item: In): void + /** Half-close the uplink: the Host's `uplink()` iteration ends. Idempotent; ignored after termination. */ + end(): void + /** + * Cancel the logical stream: send `cancel` unless a terminal frame has + * arrived, and end the downlink iterator quietly. Breaking out of + * `for await` early does the same. + */ + dispose(): void +} + /** Merge-extensible scoped Remote method signatures generated for consumers. */ export interface TypertRemoteScopeMap {} diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index ffa2c30cc9..c334beadcc 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1921,6 +1921,11 @@ "symbol": "RemoteStream", "source": "packages/typert/protocol/src/types.ts" }, + { + "doc": "docs/subsystems/typert.md", + "symbol": "RemoteStreamHandle", + "source": "packages/typert/protocol/src/types.ts" + }, { "doc": "docs/subsystems/typert.md", "symbol": "PeerScope", diff --git a/tsconfig.base.json b/tsconfig.base.json index 7d154a1a5e..41d158f02a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -82,7 +82,6 @@ "@deepseek-ai/dsh-api-workspace-controller/types": ["./packages/api/workspace-controller/src/types.ts"], "@deepseek-ai/dsh-typert-protocol": ["./packages/typert/protocol/src/index.ts"], "@deepseek-ai/dsh-typert-protocol/types": ["./packages/typert/protocol/src/types.ts"], - "@deepseek-ai/dsh-typert-protocol/client": ["./packages/typert/protocol/src/client/index.ts"], "@deepseek-ai/dsh-typert-loader": ["./packages/typert/loader/src/index.ts"], "@deepseek-ai/dsh-session/invariant": ["./packages/core/session/src/invariant.ts"], "@deepseek-ai/dsh-typert-registry/types": ["./packages/typert/registry/src/types.ts"], From e427600e32aa42173fa3632d55ee40822b2e817a Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 20 Sep 2026 12:40:09 +0800 Subject: [PATCH 22/60] test: pin the scope export roster and map dsh-brand for the proxy type program --- .../test-support/remote-mock/tests/proxy-types.client.spec.ts | 2 ++ scripts/verify-package-dependencies.spec.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/test-support/remote-mock/tests/proxy-types.client.spec.ts b/packages/test-support/remote-mock/tests/proxy-types.client.spec.ts index b1880d313f..081408f4b8 100644 --- a/packages/test-support/remote-mock/tests/proxy-types.client.spec.ts +++ b/packages/test-support/remote-mock/tests/proxy-types.client.spec.ts @@ -14,6 +14,7 @@ const artifactPath = resolve(import.meta.dirname, '__remote_proxy_artifact.d.ts' const protocolPath = resolve(root, 'packages/typert/protocol/src/types.ts') const proxyPath = resolve(import.meta.dirname, '../src/remote-proxy.ts') const protocolEntry = resolve(import.meta.dirname, '__remote_protocol.d.ts') +const brandEntry = resolve(root, 'packages/util/brand/src/index.ts') const cordisEntry = resolve(import.meta.dirname, '__remote_cordis.d.ts') function modulePath(path: string): string { @@ -40,6 +41,7 @@ function compile(source: string, artifact?: string) { types: ['node'], paths: { '@deepseek-ai/cordis': [cordisEntry], + '@deepseek-ai/dsh-brand': [brandEntry], '@deepseek-ai/dsh-typert-protocol': [protocolEntry], '@deepseek-ai/fixture/remote': [artifactPath], }, diff --git a/scripts/verify-package-dependencies.spec.ts b/scripts/verify-package-dependencies.spec.ts index 292dccbce9..2c4c147dd7 100644 --- a/scripts/verify-package-dependencies.spec.ts +++ b/scripts/verify-package-dependencies.spec.ts @@ -214,7 +214,7 @@ describe('package dependency scope', () => { expect(PACKAGE_DEPENDENCY_POLICY.safeHostDependencyExports['@deepseek-ai/dsh-session/types']).toBeUndefined() expect(PACKAGE_DEPENDENCY_POLICY.safeHostDependencyExports['@deepseek-ai/dsh-typert-protocol']).toBeUndefined() expect(PACKAGE_DEPENDENCY_POLICY.peerRequiredHostExports['@deepseek-ai/dsh-scope']).toEqual([ - 'carrierKeyOf', 'scopeOf', 'scopeTarget', + 'carrierKeyOf', 'createScope', 'scopeOf', 'scopeTarget', ]) expect(PACKAGE_DEPENDENCY_POLICY.peerRequiredHostExports['@deepseek-ai/dsh-typert-protocol']).toBeUndefined() }) From 8ce7c2767186f7b87faef3a12dfaf297406f6fd3 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 20 Sep 2026 12:48:24 +0800 Subject: [PATCH 23/60] refactor(connection): admit every request as the single operator Peer --- docs/subsystems/typert.i18n.yaml | 4 +- docs/subsystems/typert.md | 7 +- docs/subsystems/typert.zh.md | 7 +- docs/subsystems/web-server.i18n.yaml | 4 +- docs/subsystems/web-server.md | 7 +- docs/subsystems/web-server.zh.md | 7 +- packages/api/gateway/README.i18n.yaml | 4 +- packages/api/gateway/README.md | 2 +- packages/api/gateway/README.zh.md | 2 +- packages/api/gateway/src/index.ts | 2 +- .../gateway/tests/gateway-stream.host.spec.ts | 2 +- .../api/gateway/tests/gateway.host.spec.ts | 4 +- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- packages/client/connection/src/http-bridge.ts | 4 - packages/client/connection/src/index.ts | 10 +-- .../client/connection/src/operator-peer.ts | 33 +++++++ packages/client/connection/src/peer-scope.ts | 90 ------------------- packages/client/connection/src/rpc-host.ts | 39 +++----- packages/client/connection/src/rpc.ts | 39 ++------ .../connection/tests/node-half.host.spec.ts | 27 +++--- packages/client/connection/tsconfig.host.json | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 12 +-- packages/typert/protocol/src/types.ts | 7 +- scripts/gen-cordis-catalog.ts | 1 - 26 files changed, 99 insertions(+), 225 deletions(-) create mode 100644 packages/client/connection/src/operator-peer.ts delete mode 100644 packages/client/connection/src/peer-scope.ts diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index 0c750fc718..df4e0cdc23 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/typert.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/typert.md -typert.md: 74ac49ce1875deef582a464d50bc581b1791aee7 -typert.zh.md: 5ff7bd82b12797ad64fbdefaae30730a4ef16ced +typert.md: d7d109c0f4cd02baed42d30a21a2fbf36da9ec2a +typert.zh.md: 0666afb1a10dc4c9056c993ccfe23b9f8f9e4f19 diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index 74ac49ce18..d7d109c0f4 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -175,10 +175,9 @@ interface RemoteStreamHandle extends AsyncIterable { ```ts type-equiv /** - * One Peer's session on this Host. Opened and disposed by whoever admitted the - * Peer; `ctx` is the Cordis scope that owns connection-lifetime registrations. - * Who the Peer is and what it may do are not recorded here: business plugins - * attach that through `ctx` or a registry keyed by this object. + * One Peer's session on this Host. Connection owns it: `ctx` is the Cordis + * scope that owns connection-lifetime registrations and is disposed with the + * Peer. Who the Peer is and what it may do are not recorded here. */ interface PeerScope { readonly id: PeerId diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index 5ff7bd82b1..0666afb1a1 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -175,10 +175,9 @@ interface RemoteStreamHandle extends AsyncIterable { ```ts type-equiv /** - * One Peer's session on this Host. Opened and disposed by whoever admitted the - * Peer; `ctx` is the Cordis scope that owns connection-lifetime registrations. - * Who the Peer is and what it may do are not recorded here: business plugins - * attach that through `ctx` or a registry keyed by this object. + * One Peer's session on this Host. Connection owns it: `ctx` is the Cordis + * scope that owns connection-lifetime registrations and is disposed with the + * Peer. Who the Peer is and what it may do are not recorded here. */ interface PeerScope { readonly id: PeerId diff --git a/docs/subsystems/web-server.i18n.yaml b/docs/subsystems/web-server.i18n.yaml index 7c43d065a5..0024bc7600 100644 --- a/docs/subsystems/web-server.i18n.yaml +++ b/docs/subsystems/web-server.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/web-server.md -web-server.md: 83c4793d2067ab6101f51ecb94b29ef477bea3ca -web-server.zh.md: 8a22f142ef0d851b24519695928c670a23383f5b +web-server.md: 7b4d3a17a66ce4c3736b6d5d3eadf887622057c0 +web-server.zh.md: 5a95bbaff4aae20c6df05529f4154e42ccefaa3d diff --git a/docs/subsystems/web-server.md b/docs/subsystems/web-server.md index 83c4793d20..7b4d3a17a6 100644 --- a/docs/subsystems/web-server.md +++ b/docs/subsystems/web-server.md @@ -83,11 +83,10 @@ createSharedFetchHandler(channel: '/api'): ConnectionFetchHandler requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection /** - * Decide which Peer one request speaks for: a carrier bound through `peers` - * answers from its binding; every other request passes {@link requestRejection} - * and speaks for the operator. + * Admit one request: it passes {@link requestRejection} and speaks for the + * operator, or it is refused with that status. * @param request - request headers from the HTTP or upgrade request. - * @returns the admitted Peer, or the rejection status. + * @returns the operator Peer, or the rejection status. */ admit(request: ConnectionTrustRequest): PeerAdmission diff --git a/docs/subsystems/web-server.zh.md b/docs/subsystems/web-server.zh.md index 8a22f142ef..5a95bbaff4 100644 --- a/docs/subsystems/web-server.zh.md +++ b/docs/subsystems/web-server.zh.md @@ -83,11 +83,10 @@ createSharedFetchHandler(channel: '/api'): ConnectionFetchHandler requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection /** - * Decide which Peer one request speaks for: a carrier bound through `peers` - * answers from its binding; every other request passes {@link requestRejection} - * and speaks for the operator. + * Admit one request: it passes {@link requestRejection} and speaks for the + * operator, or it is refused with that status. * @param request - request headers from the HTTP or upgrade request. - * @returns the admitted Peer, or the rejection status. + * @returns the operator Peer, or the rejection status. */ admit(request: ConnectionTrustRequest): PeerAdmission diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index b08ffcb3db..bde1732d21 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/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/api/gateway/README.md -README.md: 6275cc09b2a27a6fb635ea506cc52c54e459e920 -README.zh.md: b9078fff9aeed3a2416b435c1bb53cba13ed353f +README.md: 5f36cdc4f578807d86d316fc72d200e456c15429 +README.zh.md: 09c11d0cdb04114a2f8ed9158c8fe3592e07be2b diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 6275cc09b2..5f36cdc4f5 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -34,7 +34,7 @@ A cancellation-aware Remote method declares `signal: AbortSignal` as its final H A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then returns a cancellation-aware iterable over the business items. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates and keeps it connected while idle. Connection owns the retry schedule; before each retry it asks the mux to cancel any candidate or active socket and make exactly one fresh physical attempt. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (two seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. A socket that has not answered the previous Ping is terminated at the next interval. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. -Every stream also carries a Client-to-Host uplink on the same logical stream. A method declares the uplink item type as the second type argument of its return type, `RemoteStream` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `In` defaults to `never`, and a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream` declares that it reads none. The running method reads its call as `this.ctx.invocation`: the Gateway derives the receiver from a Context carrying that `RemoteInvocation` — `request`, `service`, `peer`, `signal`, and `uplink()` — so nothing enters the parameter list, and `ctx.invocation` is `undefined` on a Context no Remote call derived. `uplink()` is available once per call. With a descriptor codec it decodes every item before delivery; without one — an SRC method, or a method whose `In` is `never` — it delivers JSON-safe `unknown` values. The Client sends each item as an `item` frame on the open logical stream and its half-close as an `end` frame; the method observes the half-close as the end of the `uplink()` iteration and cancellation through `signal`. A rejected item fails the whole stream with `gateway/input-invalid` (field `uplink`). When the method finishes its downlink the Gateway returns the uplink iterator and drops unread items; items sent to a method that never takes its uplink wait in the inbox until then. Each logical stream buffers at most `streamInboxBytes` (262144 by default) of uplink frame bytes; overflow fails the stream with `gateway/uplink-overflow`, and an item after `end` fails it with `gateway/protocol`, in both cases without closing the socket. A unary method may also call `uplink()`; its items are readable only while the method runs. Every stream a WebSocket opens speaks for the Peer Connection admitted at upgrade, and the socket closes when that Peer's scope is disposed; an in-process carrier speaks for the operator. +Every stream also carries a Client-to-Host uplink on the same logical stream. A method declares the uplink item type as the second type argument of its return type, `RemoteStream` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `In` defaults to `never`, and a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream` declares that it reads none. The running method reads its call as `this.ctx.invocation`: the Gateway derives the receiver from a Context carrying that `RemoteInvocation` — `request`, `service`, `peer`, `signal`, and `uplink()` — so nothing enters the parameter list, and `ctx.invocation` is `undefined` on a Context no Remote call derived. `uplink()` is available once per call. With a descriptor codec it decodes every item before delivery; without one — an SRC method, or a method whose `In` is `never` — it delivers JSON-safe `unknown` values. The Client sends each item as an `item` frame on the open logical stream and its half-close as an `end` frame; the method observes the half-close as the end of the `uplink()` iteration and cancellation through `signal`. A rejected item fails the whole stream with `gateway/input-invalid` (field `uplink`). When the method finishes its downlink the Gateway returns the uplink iterator and drops unread items; items sent to a method that never takes its uplink wait in the inbox until then. Each logical stream buffers at most `streamInboxBytes` (262144 by default) of uplink frame bytes; overflow fails the stream with `gateway/uplink-overflow`, and an item after `end` fails it with `gateway/protocol`, in both cases without closing the socket. A unary method may also call `uplink()`; its items are readable only while the method runs. Every stream speaks for the operator Peer: a WebSocket's streams for the Peer Connection admitted at upgrade, whose scope disposal closes the socket, and an in-process carrier's for the operator directly. Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, per-Client queues, and the Host home sent in the opening `{ type: 'ready', clientId, host: { home } }` frame. Its source factory attaches incremental listeners synchronously, so the Client publishes the generation and starts baseline reads only after incremental delivery is ready. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index b9078fff9a..09c11d0cdb 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -34,7 +34,7 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle 流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再返回可取消的业务项 iterable。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,并让它在空闲时保持连接。Connection 拥有重试调度;每次 retry 前,它要求 mux 取消候选或活动 socket,并且只做一次全新的物理连接尝试。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 2 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote 流帧。若 socket 尚未回复上一次 Ping,Host 会在下一间隔终止它。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 -每条流还在同一条逻辑流上携带从 Client 到 Host 的上行。方法把上行项类型声明为返回类型的第二个类型参数——来自 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `RemoteStream`;`In` 缺省为 `never`,返回 `Iterable`、`AsyncIterable` 或 `RemoteStream` 的方法即声明自己不读上行。运行中的方法通过 `this.ctx.invocation` 读取本次调用:Gateway 从携带该 `RemoteInvocation`(`request`、`service`、`peer`、`signal` 与 `uplink()`)的 Context 派生接收者,因此没有任何东西进入参数列表,而非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`。`uplink()` 每次调用只能取一次。描述符带 codec 时它在交付前逐项解码;不带时——SRC 方法,或 `In` 为 `never` 的方法——它交付经 JSON 安全校验的 `unknown` 值。Client 在已打开的逻辑流上把每一项作为 `item` 帧发送,以 `end` 帧表示半关闭;方法通过 `uplink()` 迭代结束感知半关闭,通过 `signal` 感知取消。被拒绝的项使整条流以 `gateway/input-invalid`(字段 `uplink`)失败。方法结束下行时 Gateway 调用 uplink 迭代器的 return 并丢弃未读的项;发给从未取用 uplink 的方法的项在 inbox 里等到那时为止。每条逻辑流最多缓冲 `streamInboxBytes`(默认 262144)字节的上行帧;超限使流以 `gateway/uplink-overflow` 失败,`end` 之后的项使流以 `gateway/protocol` 失败,两种情况都不关闭 socket。一元方法也可以调用 `uplink()`;其项只在方法运行期间可读。一条 WebSocket 打开的所有流都代表 Connection 在升级时接纳的 Peer,该 Peer 的 scope 释放时 socket 关闭;进程内载体代表操作者。 +每条流还在同一条逻辑流上携带从 Client 到 Host 的上行。方法把上行项类型声明为返回类型的第二个类型参数——来自 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `RemoteStream`;`In` 缺省为 `never`,返回 `Iterable`、`AsyncIterable` 或 `RemoteStream` 的方法即声明自己不读上行。运行中的方法通过 `this.ctx.invocation` 读取本次调用:Gateway 从携带该 `RemoteInvocation`(`request`、`service`、`peer`、`signal` 与 `uplink()`)的 Context 派生接收者,因此没有任何东西进入参数列表,而非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`。`uplink()` 每次调用只能取一次。描述符带 codec 时它在交付前逐项解码;不带时——SRC 方法,或 `In` 为 `never` 的方法——它交付经 JSON 安全校验的 `unknown` 值。Client 在已打开的逻辑流上把每一项作为 `item` 帧发送,以 `end` 帧表示半关闭;方法通过 `uplink()` 迭代结束感知半关闭,通过 `signal` 感知取消。被拒绝的项使整条流以 `gateway/input-invalid`(字段 `uplink`)失败。方法结束下行时 Gateway 调用 uplink 迭代器的 return 并丢弃未读的项;发给从未取用 uplink 的方法的项在 inbox 里等到那时为止。每条逻辑流最多缓冲 `streamInboxBytes`(默认 262144)字节的上行帧;超限使流以 `gateway/uplink-overflow` 失败,`end` 之后的项使流以 `gateway/protocol` 失败,两种情况都不关闭 socket。一元方法也可以调用 `uplink()`;其项只在方法运行期间可读。每条流都代表操作者 Peer:WebSocket 上的流代表 Connection 在升级时接纳的 Peer,该 scope 释放时 socket 关闭;进程内载体的流直接代表操作者。 Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的流。事件名单、参数校验、每个 Client 的队列及 opening `{ type: 'ready', clientId, host: { home } }` frame 中的 Host home 由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener,因此 Client 只在增量投递就绪后发布 generation 并开始 baseline 读取。 diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 91c66214fd..ff714d8fce 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -436,7 +436,7 @@ export class TypertGatewayService extends Service implements TypertGateway { */ private operatorPeer(): PeerScope { const connection = this.ctx.get('connection') - if (connection !== undefined) return connection.peers.operator + if (connection !== undefined) return connection.operator this.inProcessOperator ??= { id: 'in-process-operator' as PeerId, ctx: this.ctx, diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index 7906cf21a8..0a40849b66 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -501,7 +501,7 @@ describe('Typert Remote streams', () => { ]) }) expect(service.returns).toBe(1) - expect(service.peers.at(-1)).toBe(ctx.connection.peers.operator) + expect(service.peers.at(-1)).toBe(ctx.connection.operator) sendOpen(socket, 'rejected', 'feed/echo', { prefix: '' }) socket.send(JSON.stringify({ type: 'item', streamId: 'rejected', value: 1 })) diff --git a/packages/api/gateway/tests/gateway.host.spec.ts b/packages/api/gateway/tests/gateway.host.spec.ts index 8d58b1d553..94f75e9138 100644 --- a/packages/api/gateway/tests/gateway.host.spec.ts +++ b/packages/api/gateway/tests/gateway.host.spec.ts @@ -113,11 +113,11 @@ class FakeConnectionService extends Service { matches: ((endpoint: string) => boolean) | undefined handler: FakeRpcHandler | undefined /** The operator Peer every call this fake dispatches speaks for. */ - readonly peers: { readonly operator: PeerScope } + readonly operator: PeerScope constructor(ctx: Context) { super(ctx, 'connection') - this.peers = { operator: { id: 'fake-operator' as PeerId, ctx, dispose: () => Promise.resolve() } } + this.operator = { id: 'fake-operator' as PeerId, ctx, dispose: () => Promise.resolve() } } get rpc() { diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 074338dc60..60b9042dc5 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/connection/README.md -README.md: 136fd507da31c8ecf72750df0ba4613f0968716a -README.zh.md: 192780683ac3ba1d344436bee364a593c6d2bfc9 +README.md: 48d54096ceb0597c166c26010aec60f713b8d696 +README.zh.md: 40f0c267dfd9ed58ccfe3ef5c23b3f001eef28f6 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 136fd507da..48d54096ce 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -40,7 +40,7 @@ The cookie signing secret is the owner-scoped `client-connection/browser-session Before authentication, every request still passes `src/api-request-trust.ts`. Its `Host` must be loopback or match a `trustedHosts` entry: exact on `host:port`, any port on port-less entries, both sides WHATWG-normalized. An attached `Origin` must equal that Host and `sec-fetch-site: cross-site` is refused. Malformed configured authorities fail plugin load. These checks defend DNS rebinding and cross-site browser requests; they never establish identity. A failed Host/Origin check returns 403, while a trusted but unauthenticated request returns 401. `dsh web --host 0.0.0.0` remains unsupported. Decision records: [browser request trust](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md) and [browser token authentication](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md). -Every admitted request speaks for a Peer. `ctx.connection.peers` is the Host-wide `PeerRegistry`: `operator` is the process's own Peer; `open()` admits another party and returns a `PeerScope` whose `ctx` owns connection-lifetime registrations until `dispose()`; `bind(carrier, peer)` and `of(carrier)` associate a node request, upgrade request, WebSocket, or Fetch `Request` with the Peer it was admitted as. `ctx.connection.admit(request)` answers from a binding when one exists and otherwise runs the trust and authentication checks and answers with the operator; the `/api` route and the Gateway's WebSocket upgrade admit through it, and every RPC handler receives the Peer of its call. Who a Peer is and what it may do are not recorded here: the admitter attaches that through `peer.ctx` or a registry keyed by the scope. +Every admitted request speaks for one Peer, the operator. `ctx.connection.operator` is that `PeerScope`: its `ctx` is a Cordis scope that owns connection-lifetime registrations and is disposed with the Connection. `ctx.connection.admit(request)` runs the trust and authentication checks and answers with the rejection status or the operator; the `/api` route and the Gateway's WebSocket upgrade admit through it, and every RPC handler receives the Peer of its call. Authenticated shared HTTP requests pass through the `connection/request` waterfall before body transfer. A listener may refuse new requests or await `next()` through response completion; removing its owning fiber removes admission behavior. Desktop uses this hook to lock new API work during an approved installation without canceling already-admitted work. WebSocket stream ownership remains with API Gateway. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 192780683a..40f0c267df 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -40,7 +40,7 @@ cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-sessi 认证之前,每个请求仍经过 `src/api-request-trust.ts`。其 `Host` 必须是 loopback,或与 `trustedHosts` 条目匹配:带端口的 `host:port` 精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化。若附带 `Origin`,它必须等于该 Host;`sec-fetch-site: cross-site` 一律拒绝。畸形配置 authority 会让插件加载失败。这些检查防御 DNS rebinding 与跨站浏览器请求,绝不建立身份。Host/Origin 校验失败返回 403;Host 可信但未认证的请求返回 401。`dsh web --host 0.0.0.0` 仍不受支持。决策记录:[浏览器请求信任](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)与[浏览器令牌认证](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md)。 -每个被接纳的请求都代表一个 Peer。`ctx.connection.peers` 是 Host 范围的 `PeerRegistry`:`operator` 是进程自己的 Peer;`open()` 接纳另一方并返回一个 `PeerScope`,其 `ctx` 拥有连接期注册直到 `dispose()`;`bind(carrier, peer)` 与 `of(carrier)` 把 node 请求、升级请求、WebSocket 或 Fetch `Request` 与接纳它的 Peer 关联。`ctx.connection.admit(request)` 在存在绑定时按绑定作答,否则执行信任与认证检查并以操作者作答;`/api` 路由与 Gateway 的 WebSocket 升级都经它接纳,每个 RPC 处理器都收到本次调用的 Peer。Peer 是谁、能做什么不记录在这里:接纳方通过 `peer.ctx` 或按 scope 键的注册表自行附加。 +每个被接纳的请求都代表同一个 Peer——操作者。`ctx.connection.operator` 就是这个 `PeerScope`:其 `ctx` 是拥有连接期注册的 Cordis scope,随 Connection 一起释放。`ctx.connection.admit(request)` 执行信任与认证检查,以拒绝状态或操作者作答;`/api` 路由与 Gateway 的 WebSocket 升级都经它接纳,每个 RPC 处理器都收到本次调用的 Peer。 通过认证的共享 HTTP 请求在传输请求体之前经过 `connection/request` waterfall。监听器可以拒绝新请求,或等待 `next()` 直到响应完成;释放所属 fiber 会移除准入行为。Desktop 使用此扩展点,在已批准的安装期间锁住新的 API 工作,而不取消已接纳的工作。WebSocket 流仍由 API Gateway 负责。 diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts index cdb14c1685..ff6042d117 100644 --- a/packages/client/connection/src/http-bridge.ts +++ b/packages/client/connection/src/http-bridge.ts @@ -20,15 +20,12 @@ export const DEFAULT_MAX_REQUEST_BODY_BYTES = 300 * 1024 * 1024 * @param res - node:http response the bridge writes and owns to completion. * @param apiHandler - fetch-shaped API carrier the request is dispatched to. * @param maxRequestBodyBytes - maximum bytes buffered for a buffered route. - * @param bind - runs on the built Fetch `Request` before dispatch, so the - * caller can bind it to the Peer the node request was admitted as. */ export async function bridge( req: IncomingMessage, res: ServerResponse, apiHandler: ConnectionFetchHandler, maxRequestBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES, - bind?: (request: Request) => void, ): Promise { const abort = new AbortController() // Client-disconnect detection MUST hang off the response, not the request: @@ -83,7 +80,6 @@ export async function bridge( duplex: 'half', } as RequestInit & { duplex: 'half' }) } - bind?.(request) const response = await apiHandler.fetch(request) const requestUnread = bodyMode === 'streaming' && !req.readableEnded const responseHeaders = Object.fromEntries(response.headers.entries()) diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 19848ac0e8..5aeed85337 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -15,7 +15,6 @@ import { ConnectionRecoveryConfigSchema, resolveConnectionConfig, type Connectio export type { PeerAdmission, - PeerRegistryHandle, ConnectionFetchMethod, ConnectionFetchHandler, ConnectionFetchRoute, @@ -37,7 +36,6 @@ export type { } from './rpc.ts' export type { PeerId, PeerScope, RemoteInvocation } from '@deepseek-ai/dsh-typert-protocol' export { RpcId, transportError } from './rpc.ts' -export { PeerRegistry } from './peer-scope.ts' export { clientRequestSchema, rpcErrorSchema, @@ -151,13 +149,7 @@ export async function apply(ctx: Context, config?: ConnectionConfig): Promise bridge( - req, - res, - fetchHandler, - maxRequestBodyBytes, - (request) => { connection.peers.bind(request, admission.peer) }, - )) + await webCtx.waterfall('connection/request', req, res, () => bridge(req, res, fetchHandler, maxRequestBodyBytes)) }, } webCtx.effect(() => webCtx.webServer.register(route), 'client-connection: /api route') diff --git a/packages/client/connection/src/operator-peer.ts b/packages/client/connection/src/operator-peer.ts new file mode 100644 index 0000000000..83325f78e7 --- /dev/null +++ b/packages/client/connection/src/operator-peer.ts @@ -0,0 +1,33 @@ +/** + * The operator Peer: the one party this Host answers to. Connection owns it + * for its own lifetime, admits every request as it, and hands it to each + * Remote call as `invocation.peer`. + * @module @deepseek-ai/dsh-client-connection/src/operator-peer + */ + +import { randomUUID } from 'node:crypto' +import type { Context } from '@deepseek-ai/cordis' +import { createScope, type Scope } from '@deepseek-ai/dsh-scope' +import type { PeerId, PeerScope } from '@deepseek-ai/dsh-typert-protocol' + +/** + * The operator's scope. The instance is its own scope key, so `scopeOf(peer.ctx)` + * returns it and events dispatched with `scopeTarget(subject, peer)` reach + * listeners registered through `peer.ctx` and nobody else. + */ +export class OperatorPeer implements PeerScope { + readonly id: PeerId = randomUUID() as PeerId + readonly ctx: Context + private readonly scope: Scope + + /** @param owner - Connection plugin context the scope fiber hangs under. */ + constructor(owner: Context) { + this.scope = createScope(owner, this) + this.ctx = this.scope.ctx + } + + /** Tear down every connection-lifetime registration; racing calls share one completion. */ + dispose(): Promise { + return this.scope.dispose() + } +} diff --git a/packages/client/connection/src/peer-scope.ts b/packages/client/connection/src/peer-scope.ts deleted file mode 100644 index d2c7490c34..0000000000 --- a/packages/client/connection/src/peer-scope.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Peer registry: one `PeerScope` per party admitted to this Host. - * - * The framework knows a Peer only by its `PeerId` and the scope it owns. Who - * the Peer is and what it may do belong to whoever admitted it; that party - * attaches its state through `peer.ctx` or a registry keyed by the scope. - * @module @deepseek-ai/dsh-client-connection/src/peer-scope - */ - -import { randomUUID } from 'node:crypto' -import type { Context } from '@deepseek-ai/cordis' -import { createScope, type Scope } from '@deepseek-ai/dsh-scope' -import type { PeerId, PeerScope } from '@deepseek-ai/dsh-typert-protocol' -import type { PeerRegistryHandle } from './rpc.ts' - -/** - * One admitted Peer. The instance is its own scope key, so `scopeOf(peer.ctx)` - * returns it and events dispatched with `scopeTarget(subject, peer)` reach - * listeners registered through `peer.ctx` and nobody else. - */ -class AdmittedPeer implements PeerScope { - readonly id: PeerId = randomUUID() as PeerId - readonly ctx: Context - private readonly scope: Scope - private disposing: Promise | undefined - - /** - * @param owner - Connection plugin context the scope fiber hangs under. - * @param onClosed - registry callback run once disposal completes. - */ - constructor(owner: Context, private readonly onClosed: (peer: AdmittedPeer) => void) { - this.scope = createScope(owner, this) - this.ctx = this.scope.ctx - } - - /** Tear down every connection-lifetime registration; racing calls share one completion. */ - dispose(): Promise { - this.disposing ??= this.scope.dispose().then(() => { this.onClosed(this) }) - return this.disposing - } -} - -/** Host-wide registry of Peers and the carriers that speak for them. */ -export class PeerRegistry implements PeerRegistryHandle { - readonly operator: PeerScope - private readonly peers = new Set() - private readonly carriers = new WeakMap() - - /** @param ctx - Connection plugin context; every scope fiber is its child. */ - constructor(private readonly ctx: Context) { - this.operator = this.open() - } - - /** - * Admit one Peer on behalf of an admitter. - * @returns the opened scope; the admitter disposes it when the Peer leaves. - */ - open(): PeerScope { - const peer = new AdmittedPeer(this.ctx, (closed) => { this.peers.delete(closed) }) - this.peers.add(peer) - return peer - } - - /** - * Record which Peer one carrier object speaks for. - * @param carrier - node request, upgrade request, WebSocket, or Fetch `Request`. - * @param peer - the Peer it was admitted as. - */ - bind(carrier: object, peer: PeerScope): void { - this.carriers.set(carrier, peer) - } - - /** - * Read the Peer a carrier object was bound to. - * @param carrier - node request, upgrade request, WebSocket, or Fetch `Request`. - * @returns the bound Peer, or undefined when nobody bound one. - */ - of(carrier: object): PeerScope | undefined { - return this.carriers.get(carrier) - } - - /** - * Dispose every open Peer, the operator included. Each scope's registrations - * are torn down; sockets bound to a Peer close with it. - * @returns settles once every scope has quiesced. - */ - async dispose(): Promise { - await Promise.all([...this.peers].map(peer => peer.dispose())) - } -} diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index 8cb658cd46..ba6f2736d6 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -13,7 +13,7 @@ import { bridge } from './http-bridge.ts' import { isTrustedApiRequest } from './api-request-trust.ts' import { API_PATH } from './api-path.ts' import type { BrowserAuth } from './browser-auth.ts' -import { PeerRegistry } from './peer-scope.ts' +import { OperatorPeer } from './operator-peer.ts' import type { PeerAdmission, ConnectionIndexRequest, @@ -35,9 +35,6 @@ const INVALID_REQUEST_RPC_ID = RpcId('invalid-request') const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ -/** Resolve the Peer one Fetch request speaks for. */ -type PeerResolver = (request: Request) => PeerScope - interface ConnectionRpcInterceptor { readonly matches: ConnectionRpcEndpointMatcher readonly fetchHandler: ConnectionFetchHandler @@ -64,8 +61,8 @@ declare module '@deepseek-ai/cordis' { /** Host Connection service whose channel registrations belong to the caller fiber. */ export class HostConnectionService extends Service implements HostConnectionHandle { - /** Peers this Host answers to. */ - readonly peers: PeerRegistry + /** The operator Peer every admitted request speaks for. */ + readonly operator: PeerScope private readonly interceptors = new Map() private readonly fetchRoutes = new Map() @@ -81,8 +78,8 @@ export class HostConnectionService extends Service implements HostConnectionHand private readonly browserAuth: BrowserAuth, ) { super(ctx, 'connection') - this.peers = new PeerRegistry(ctx) - ctx.effect(() => () => this.peers.dispose(), 'client-connection: Peer registry') + this.operator = new OperatorPeer(ctx) + ctx.effect(() => () => this.operator.dispose(), 'client-connection: operator Peer') } /** Generic channel registry scoped to the Context reading this service. */ @@ -109,22 +106,10 @@ export class HostConnectionService extends Service implements HostConnectionHand return this.browserAuth.isAuthenticated(request) ? undefined : 401 } - /** A bound carrier answers from its binding; everything else passes the fence and speaks for the operator. */ + /** A request that passes the fence and authentication speaks for the operator. */ admit(request: ConnectionTrustRequest): PeerAdmission { - const bound = this.peers.of(request) - if (bound !== undefined) return { peer: bound } const rejection = this.requestRejection(request) - return rejection === undefined ? { peer: this.peers.operator } : { rejection } - } - - /** - * Resolve the Peer one Fetch request speaks for. A request nobody bound came - * from an in-process carrier the operator owns outright. - * @param request - dispatched Fetch request. - * @returns the bound Peer, or the operator. - */ - private peerOf(request: Request): PeerScope { - return this.peers.of(request) ?? this.peers.operator + return rejection === undefined ? { peer: this.operator } : { rejection } } /** Authenticate an index request through the process-token exchange or cookie. */ @@ -189,7 +174,7 @@ export class HostConnectionService extends Service implements HostConnectionHand handler: ConnectionRpcHandler, ): () => Promise { assertChannel(channel) - const fetchHandler = rpcFetchHandler(channel, handler, request => this.peerOf(request)) + const fetchHandler = rpcFetchHandler(channel, handler, this.operator) const route: WebRoute = { kind: 'prefix', path: channel, @@ -200,7 +185,7 @@ export class HostConnectionService extends Service implements HostConnectionHand res.end(admission.rejection === 401 ? 'unauthorized' : 'forbidden') return } - await bridge(req, res, fetchHandler, undefined, (request) => { this.peers.bind(request, admission.peer) }) + await bridge(req, res, fetchHandler) }, } return owner.effect( @@ -220,7 +205,7 @@ export class HostConnectionService extends Service implements HostConnectionHand } const interceptor: ConnectionRpcInterceptor = { matches, - fetchHandler: rpcFetchHandler(channel, handler, request => this.peerOf(request)), + fetchHandler: rpcFetchHandler(channel, handler, this.operator), } return owner.effect(() => { if (this.interceptors.has(channel)) { @@ -237,7 +222,7 @@ export class HostConnectionService extends Service implements HostConnectionHand function rpcFetchHandler( channel: string, handler: ConnectionRpcHandler, - peerOf: PeerResolver, + peer: PeerScope, ): ConnectionFetchHandler { return { requestBodyMode: () => 'buffered', @@ -273,7 +258,7 @@ function rpcFetchHandler( } try { - const result = await handler(endpoint, message.payload, request.signal, peerOf(request)) + const result = await handler(endpoint, message.payload, request.signal, peer) return fullResponse(message.rpcId, result) } catch (error) { return new Response(`handler failure: ${String(error)}`, { status: 500 }) diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index 85ab3b91b8..e77562acfd 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -97,38 +97,14 @@ export interface ConnectionIndexResponse { end(body?: string): unknown } -/** Outcome of admitting one request: the Peer it speaks for, or the status refusing it. */ +/** Outcome of admitting one request: the operator Peer it speaks for, or the status refusing it. */ export type PeerAdmission = | { readonly peer: PeerScope } | { readonly rejection: 401 | 403 } -/** Registry of the Peers this Host answers to. */ -export interface PeerRegistryHandle { - /** The operator's own Peer: every browser-cookie request and every in-process carrier speaks for it. */ - readonly operator: PeerScope - /** - * Admit one Peer on behalf of an admitter. What the Peer may do is not - * recorded here: the admitter attaches its own policy through the scope. - * @returns the opened scope; the admitter disposes it when the Peer leaves. - */ - open(): PeerScope - /** - * Record which Peer one carrier object speaks for, before it is dispatched. - * @param carrier - node request, upgrade request, WebSocket, or Fetch `Request`. - * @param peer - the Peer it was admitted as. - */ - bind(carrier: object, peer: PeerScope): void - /** - * Read the Peer a carrier object was bound to. - * @param carrier - node request, upgrade request, WebSocket, or Fetch `Request`. - * @returns the bound Peer, or undefined when nobody bound one. - */ - of(carrier: object): PeerScope | undefined -} - /** * Handler invoked after Connection has decoded the transport envelope. - * `peer` is the Peer the request was admitted as. + * `peer` is the Peer the request was admitted as: the operator. */ export type ConnectionRpcHandler = ( endpoint: string, @@ -201,8 +177,8 @@ export interface HostConnectionHandle { readonly rpc: HostConnectionRpc /** Exact Fetch routes for streaming or browser-native responses. */ readonly fetch: HostConnectionFetch - /** Peers this Host answers to. */ - readonly peers: PeerRegistryHandle + /** The operator Peer every admitted request speaks for; its scope lives as long as Connection. */ + readonly operator: PeerScope /** * Compose exact Fetch routes and the shared-channel RPC interceptor. @@ -220,11 +196,10 @@ export interface HostConnectionHandle { requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection /** - * Decide which Peer one request speaks for: a carrier bound through `peers` - * answers from its binding; every other request passes {@link requestRejection} - * and speaks for the operator. + * Admit one request: it passes {@link requestRejection} and speaks for the + * operator, or it is refused with that status. * @param request - request headers from the HTTP or upgrade request. - * @returns the admitted Peer, or the rejection status. + * @returns the operator Peer, or the rejection status. */ admit(request: ConnectionTrustRequest): PeerAdmission diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index ccf69c9b32..51a0bbbf67 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -478,7 +478,7 @@ describe('connection node half', () => { await fiber.dispose() }) - it('admits a bound carrier as its Peer and hands every call the Peer it speaks for', async () => { + it('admits every trusted, authenticated request as the operator Peer and hands each call that Peer', async () => { const { connection, routes, dispose } = await mounted({ trustedHosts: ['harness.example'] }) const peers: PeerScope[] = [] const remove = connection.rpc.intercept( @@ -489,13 +489,10 @@ describe('connection node half', () => { return { ok: true, value: null } }, ) - const admitted = connection.peers.open() - expect(admitted).not.toBe(connection.peers.operator) - const untrusted = fakeRequest({ host: 'other.example' }) - expect(connection.admit(untrusted)).toEqual({ rejection: 403 }) - connection.peers.bind(untrusted, admitted) - expect(connection.admit(untrusted)).toEqual({ peer: admitted }) - expect(connection.peers.of({})).toBeUndefined() + expect(connection.admit(fakeRequest({ host: 'other.example' }))).toEqual({ rejection: 403 }) + expect(connection.admit(fakeRequest({ host: '127.0.0.1:3080' }))).toEqual({ rejection: 401 }) + const cookie = browserCookie(connection, '127.0.0.1:3080') + expect(connection.admit(fakeRequest({ host: '127.0.0.1:3080', cookie }))).toEqual({ peer: connection.operator }) const route = routes.find(candidate => candidate.path === API_PATH)! const request: ClientRequest = { @@ -504,24 +501,20 @@ describe('connection node half', () => { method: 'goals/create', payload: { args: {} }, } - const boundPost = fakePost({ host: 'other.example' }, '/api/goals/create', request) - connection.peers.bind(boundPost, admitted) const answered = fakeResponse() - await route.handler(boundPost, answered.response) + await route.handler(fakePost({ host: '127.0.0.1:3080', cookie }, '/api/goals/create', request), answered.response) expect(JSON.parse(String(answered.state.body))).toMatchObject({ result: { ok: true, value: null } }) - const cookie = browserCookie(connection, '127.0.0.1:3080') - await route.handler(fakePost({ host: '127.0.0.1:3080', cookie }, '/api/goals/create', request), fakeResponse().response) - // A shell-owned carrier dispatches the shared handler without the bridge; nobody bound its Request. + // A shell-owned carrier dispatches the shared handler without the bridge and speaks for the operator too. const direct = await connection.createSharedFetchHandler('/api').fetch(new Request('http://127.0.0.1:3080/api/goals/create', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(request), })) expect(direct.status).toBe(200) - expect(peers).toEqual([admitted, connection.peers.operator, connection.peers.operator]) + expect(peers).toEqual([connection.operator, connection.operator]) - await admitted.dispose() - await admitted.dispose() + // Racing disposals share one completion, and the scope goes with the Connection. + await Promise.all([connection.operator.dispose(), connection.operator.dispose()]) await remove() await dispose() }) diff --git a/packages/client/connection/tsconfig.host.json b/packages/client/connection/tsconfig.host.json index b06759cb49..c291ff868d 100644 --- a/packages/client/connection/tsconfig.host.json +++ b/packages/client/connection/tsconfig.host.json @@ -12,7 +12,7 @@ "src/http-bridge.ts", "src/index.ts", "src/loopback-hostname.ts", - "src/peer-scope.ts", + "src/operator-peer.ts", "src/recovery-config.ts", "src/rpc-host.ts", "src/rpc-schema.ts", diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index d15c4626b1..72c301c872 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -749,8 +749,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [], }, { - signature: 'readonly peers: PeerRegistryHandle', - description: 'Peers this Host answers to.', + signature: 'readonly operator: PeerScope', + description: 'The operator Peer every admitted request speaks for; its scope lives as long as Connection.', parameters: [], }, { @@ -767,9 +767,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'admit(request: ConnectionTrustRequest): PeerAdmission', - description: 'Decide which Peer one request speaks for: a carrier bound through `peers` answers from its binding; every other request passes requestRejection and speaks for the operator.', + description: 'Admit one request: it passes requestRejection and speaks for the operator, or it is refused with that status.', parameters: [{ name: 'request', description: 'request headers from the HTTP or upgrade request.' }], - returns: 'the admitted Peer, or the rejection status.', + returns: 'the operator Peer, or the rejection status.', }, { signature: 'authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean', @@ -5334,10 +5334,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PeerId', declaration: 'export type PeerId = Branded<\'PeerId\'>;', }, - { - name: 'PeerRegistryHandle', - declaration: 'export interface PeerRegistryHandle {\n readonly operator: PeerScope;\n open(): PeerScope;\n bind(carrier: object, peer: PeerScope): void;\n of(carrier: object): PeerScope | undefined;\n}', - }, { name: 'PeerScope', declaration: 'export interface PeerScope {\n readonly id: PeerId;\n readonly ctx: Context;\n dispose(): Promise;\n}', diff --git a/packages/typert/protocol/src/types.ts b/packages/typert/protocol/src/types.ts index 3e5062adcb..4102fbe0a3 100644 --- a/packages/typert/protocol/src/types.ts +++ b/packages/typert/protocol/src/types.ts @@ -358,10 +358,9 @@ export interface InvocationDescriptor { export type PeerId = Branded<'PeerId'> /** - * One Peer's session on this Host. Opened and disposed by whoever admitted the - * Peer; `ctx` is the Cordis scope that owns connection-lifetime registrations. - * Who the Peer is and what it may do are not recorded here: business plugins - * attach that through `ctx` or a registry keyed by this object. + * One Peer's session on this Host. Connection owns it: `ctx` is the Cordis + * scope that owns connection-lifetime registrations and is disposed with the + * Peer. Who the Peer is and what it may do are not recorded here. */ export interface PeerScope { readonly id: PeerId diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index fa9406d94e..f2203dfcde 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -753,7 +753,6 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { ConnectionRequestRejection: 'transport rejection status is owned by packages/client/connection/src/rpc.ts', ConnectionTrustRequest: 'transport authentication input is owned by packages/client/connection/src/rpc.ts', PeerAdmission: 'Peer admission outcome is owned by packages/client/connection/src/rpc.ts', - PeerRegistryHandle: 'Peer registry contract is owned by packages/client/connection/src/rpc.ts', PeerScope: 'Peer scope contract is owned by packages/typert/protocol/src/types.ts', ConnectionIndexRequest: 'frontend authentication request is owned by packages/client/connection/src/rpc.ts', ConnectionIndexResponse: 'frontend authentication response is owned by packages/client/connection/src/rpc.ts', From 355df025d45eda1e9a423a87b75d87c890568575 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 20 Sep 2026 13:20:59 +0800 Subject: [PATCH 24/60] fix(gateway): harden the Remote uplink lifecycle --- packages/api/gateway/README.i18n.yaml | 4 +- packages/api/gateway/README.md | 2 +- packages/api/gateway/README.zh.md | 2 +- packages/api/gateway/package.json | 3 +- packages/api/gateway/src/client/index.ts | 76 ++-------- .../api/gateway/src/client/stream-client.ts | 140 +++++++++++++++--- packages/api/gateway/src/index.ts | 25 ++-- packages/api/gateway/src/stream-server.ts | 42 ++++-- .../gateway/tests/gateway-stream.host.spec.ts | 42 +++++- .../api/gateway/tests/gateway.client.spec.ts | 84 +++++++++++ .../gateway/tests/stream-server.host.spec.ts | 40 +++-- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- packages/client/connection/src/index.ts | 1 + .../webworker-runtime/README.i18n.yaml | 4 +- .../experimental/webworker-runtime/README.md | 2 +- .../webworker-runtime/README.zh.md | 2 +- .../test-support/remote-mock/README.i18n.yaml | 4 +- packages/test-support/remote-mock/README.md | 2 +- .../test-support/remote-mock/README.zh.md | 2 +- .../test-support/remote-mock/src/index.ts | 1 + .../remote-mock/src/remote-mock.ts | 46 +++--- .../test-support/remote-mock/src/streams.ts | 92 +++++++++++- .../remote-mock/tests/rpc.client.spec.ts | 21 ++- packages/typert/protocol/README.i18n.yaml | 4 +- packages/typert/protocol/README.md | 2 +- packages/typert/protocol/README.zh.md | 2 +- packages/typert/protocol/src/index.ts | 12 ++ .../typert/protocol/tests/protocol.spec.ts | 28 ++++ scripts/package-dependency-policy.ts | 1 + 31 files changed, 517 insertions(+), 177 deletions(-) diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index bde1732d21..95e9625c6c 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/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/api/gateway/README.md -README.md: 5f36cdc4f578807d86d316fc72d200e456c15429 -README.zh.md: 09c11d0cdb04114a2f8ed9158c8fe3592e07be2b +README.md: 22ffbe01623b817968752e5f19e2c1aca03cd212 +README.zh.md: edf4d7ae9ee66cf7156c1aea87a2330b475e3732 diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 5f36cdc4f5..22ffbe0162 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -34,7 +34,7 @@ A cancellation-aware Remote method declares `signal: AbortSignal` as its final H A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then returns a cancellation-aware iterable over the business items. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates and keeps it connected while idle. Connection owns the retry schedule; before each retry it asks the mux to cancel any candidate or active socket and make exactly one fresh physical attempt. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (two seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. A socket that has not answered the previous Ping is terminated at the next interval. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. -Every stream also carries a Client-to-Host uplink on the same logical stream. A method declares the uplink item type as the second type argument of its return type, `RemoteStream` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `In` defaults to `never`, and a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream` declares that it reads none. The running method reads its call as `this.ctx.invocation`: the Gateway derives the receiver from a Context carrying that `RemoteInvocation` — `request`, `service`, `peer`, `signal`, and `uplink()` — so nothing enters the parameter list, and `ctx.invocation` is `undefined` on a Context no Remote call derived. `uplink()` is available once per call. With a descriptor codec it decodes every item before delivery; without one — an SRC method, or a method whose `In` is `never` — it delivers JSON-safe `unknown` values. The Client sends each item as an `item` frame on the open logical stream and its half-close as an `end` frame; the method observes the half-close as the end of the `uplink()` iteration and cancellation through `signal`. A rejected item fails the whole stream with `gateway/input-invalid` (field `uplink`). When the method finishes its downlink the Gateway returns the uplink iterator and drops unread items; items sent to a method that never takes its uplink wait in the inbox until then. Each logical stream buffers at most `streamInboxBytes` (262144 by default) of uplink frame bytes; overflow fails the stream with `gateway/uplink-overflow`, and an item after `end` fails it with `gateway/protocol`, in both cases without closing the socket. A unary method may also call `uplink()`; its items are readable only while the method runs. Every stream speaks for the operator Peer: a WebSocket's streams for the Peer Connection admitted at upgrade, whose scope disposal closes the socket, and an in-process carrier's for the operator directly. +Every stream also carries a Client-to-Host uplink on the same logical stream. A method declares the uplink item type as the second type argument of its return type, `RemoteStream` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `In` defaults to `never`, and a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream` declares that it reads none. The running method reads its call as `this.ctx.invocation`: the Gateway derives the receiver from a Context carrying that `RemoteInvocation` — `request`, `service`, `peer`, `signal`, and `uplink()` — so nothing enters the parameter list, and `ctx.invocation` is `undefined` on a Context no Remote call derived. `uplink()` is available once per call. With a descriptor codec it decodes every item before delivery; without one — an SRC method, or a method whose `In` is `never` — it delivers JSON-safe `unknown` values. The Client sends each item as an `item` frame on the open logical stream and its half-close as an `end` frame; the method observes the half-close as the end of the `uplink()` iteration and cancellation through `signal`. A rejected item fails the whole stream with `gateway/input-invalid` (field `uplink`). When the method finishes its downlink the Gateway returns the uplink iterator and drops unread items; items sent to a method that never takes its uplink wait in the inbox until then. Each logical stream buffers at most `streamInboxBytes` (262144 by default) of uplink frame bytes; overflow fails the stream with `gateway/uplink-overflow`, and an item after `end` fails it with `gateway/protocol`, in both cases without closing the socket. `item`, `end`, and `cancel` frames for a stream id the Host has already finished are dropped; only a duplicate `open` closes the socket. A unary method may also call `uplink()`; its items are readable only while the method runs. Every stream speaks for the operator Peer: a WebSocket's streams for the Peer Connection admitted at upgrade, whose scope disposal closes the socket, and an in-process carrier's for the operator directly. Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, per-Client queues, and the Host home sent in the opening `{ type: 'ready', clientId, host: { home } }` frame. Its source factory attaches incremental listeners synchronously, so the Client publishes the generation and starts baseline reads only after incremental delivery is ready. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 09c11d0cdb..edf4d7ae9e 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -34,7 +34,7 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle 流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再返回可取消的业务项 iterable。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,并让它在空闲时保持连接。Connection 拥有重试调度;每次 retry 前,它要求 mux 取消候选或活动 socket,并且只做一次全新的物理连接尝试。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 2 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote 流帧。若 socket 尚未回复上一次 Ping,Host 会在下一间隔终止它。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 -每条流还在同一条逻辑流上携带从 Client 到 Host 的上行。方法把上行项类型声明为返回类型的第二个类型参数——来自 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `RemoteStream`;`In` 缺省为 `never`,返回 `Iterable`、`AsyncIterable` 或 `RemoteStream` 的方法即声明自己不读上行。运行中的方法通过 `this.ctx.invocation` 读取本次调用:Gateway 从携带该 `RemoteInvocation`(`request`、`service`、`peer`、`signal` 与 `uplink()`)的 Context 派生接收者,因此没有任何东西进入参数列表,而非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`。`uplink()` 每次调用只能取一次。描述符带 codec 时它在交付前逐项解码;不带时——SRC 方法,或 `In` 为 `never` 的方法——它交付经 JSON 安全校验的 `unknown` 值。Client 在已打开的逻辑流上把每一项作为 `item` 帧发送,以 `end` 帧表示半关闭;方法通过 `uplink()` 迭代结束感知半关闭,通过 `signal` 感知取消。被拒绝的项使整条流以 `gateway/input-invalid`(字段 `uplink`)失败。方法结束下行时 Gateway 调用 uplink 迭代器的 return 并丢弃未读的项;发给从未取用 uplink 的方法的项在 inbox 里等到那时为止。每条逻辑流最多缓冲 `streamInboxBytes`(默认 262144)字节的上行帧;超限使流以 `gateway/uplink-overflow` 失败,`end` 之后的项使流以 `gateway/protocol` 失败,两种情况都不关闭 socket。一元方法也可以调用 `uplink()`;其项只在方法运行期间可读。每条流都代表操作者 Peer:WebSocket 上的流代表 Connection 在升级时接纳的 Peer,该 scope 释放时 socket 关闭;进程内载体的流直接代表操作者。 +每条流还在同一条逻辑流上携带从 Client 到 Host 的上行。方法把上行项类型声明为返回类型的第二个类型参数——来自 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `RemoteStream`;`In` 缺省为 `never`,返回 `Iterable`、`AsyncIterable` 或 `RemoteStream` 的方法即声明自己不读上行。运行中的方法通过 `this.ctx.invocation` 读取本次调用:Gateway 从携带该 `RemoteInvocation`(`request`、`service`、`peer`、`signal` 与 `uplink()`)的 Context 派生接收者,因此没有任何东西进入参数列表,而非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`。`uplink()` 每次调用只能取一次。描述符带 codec 时它在交付前逐项解码;不带时——SRC 方法,或 `In` 为 `never` 的方法——它交付经 JSON 安全校验的 `unknown` 值。Client 在已打开的逻辑流上把每一项作为 `item` 帧发送,以 `end` 帧表示半关闭;方法通过 `uplink()` 迭代结束感知半关闭,通过 `signal` 感知取消。被拒绝的项使整条流以 `gateway/input-invalid`(字段 `uplink`)失败。方法结束下行时 Gateway 调用 uplink 迭代器的 return 并丢弃未读的项;发给从未取用 uplink 的方法的项在 inbox 里等到那时为止。每条逻辑流最多缓冲 `streamInboxBytes`(默认 262144)字节的上行帧;超限使流以 `gateway/uplink-overflow` 失败,`end` 之后的项使流以 `gateway/protocol` 失败,两种情况都不关闭 socket。发给 Host 已结束的流 id 的 `item`、`end` 与 `cancel` 帧被丢弃;只有重复的 `open` 会关闭 socket。一元方法也可以调用 `uplink()`;其项只在方法运行期间可读。每条流都代表操作者 Peer:WebSocket 上的流代表 Connection 在升级时接纳的 Peer,该 scope 释放时 socket 关闭;进程内载体的流直接代表操作者。 Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的流。事件名单、参数校验、每个 Client 的队列及 opening `{ type: 'ready', clientId, host: { home } }` frame 中的 Host home 由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener,因此 Client 只在增量投递就绪后发布 generation 并开始 baseline 读取。 diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index 4680b06f39..e2359db6be 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -58,7 +58,8 @@ "@deepseek-ai/dsh-typert-protocol": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index 161943ce6f..c189fa464b 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -5,7 +5,6 @@ */ import { Service } from '@deepseek-ai/cordis' -import { Deque } from '@deepseek-ai/dsh-deque' import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' export type { TypertGatewayFaultDetails } from '../remote-error-codes.ts' import type { Context } from '@deepseek-ai/cordis' @@ -25,9 +24,11 @@ import type { TypertRemoteEvent, } from '@deepseek-ai/dsh-typert-protocol' import { + ClientUplinkQueue, RemoteStreamCarrierError, RemoteStreamMuxClient, } from './stream-client.ts' +import { isRemoteJsonValue } from '../stream-protocol.ts' import { ClientRemoteEvents } from './remote-events.ts' import { RemoteStream, @@ -82,8 +83,6 @@ interface PreparedClientInvocation { readonly signal: AbortSignal } -const UPLINK_DONE: IteratorReturnResult = { value: undefined, done: true } - interface RemoteNamespaceHandle { readonly service: RemoteNamespaceService readonly dispose: TypertDisposer @@ -534,8 +533,9 @@ class ClientRemoteService extends Service implements ClientRemote { /** * The handle a generated stream method returns: one generation of one logical * stream. The downlink is iterated once; `send`/`end` feed the uplink queue the - * carrier pump drains; `dispose` aborts the generation, which sends `cancel` - * unless a terminal frame arrived, and the iterator then ends quietly. + * carrier pump drains; `dispose` aborts the generation and returns the carrier + * iterator, which sends `cancel` unless a terminal frame arrived, drops what was + * buffered, and ends the iteration quietly. */ class ClientStreamHandle implements RemoteStreamHandle { private readonly downlink: AsyncIterator @@ -559,6 +559,7 @@ class ClientStreamHandle implements RemoteStreamHandle { } send(item: unknown): void { + if (!isRemoteJsonValue(item)) throw new Error(`client api: ${this.endpoint} uplink item is not a lossless JSON value`) this.uplink.push(item) } @@ -571,6 +572,9 @@ class ClientStreamHandle implements RemoteStreamHandle { this.disposed = true this.uplink.close() this.generation.abort(new Error(`client api: ${this.endpoint} stream disposed`)) + // Returning the carrier iterator reaches its `finally` even while nobody + // reads the downlink, so `cancel` goes out now and the pump stops. + void Promise.resolve(this.downlink.return?.()).catch(() => undefined) } [Symbol.asyncIterator](): AsyncGenerator { @@ -584,7 +588,7 @@ class ClientStreamHandle implements RemoteStreamHandle { while (true) { const next = await (this.primed ?? this.downlink.next()) this.primed = undefined - if (next.done === true) return + if (this.disposed || next.done === true) return if (!mountActive(this.token)) throw new Error(withdrawn(this.endpoint).error.message) yield next.value } @@ -599,66 +603,6 @@ class ClientStreamHandle implements RemoteStreamHandle { } } -/** - * Uplink items a stream handle queues for its carrier: the mux pump or the - * in-process Host decoder iterates it as the stream's uplink. `end()` is the - * Client half-close; `close()` marks the stream terminated, after which - * `push()` throws. - */ -class ClientUplinkQueue implements AsyncIterable, AsyncIterator { - private readonly items = new Deque() - private ended = false - private closed = false - private wake: (() => void) | undefined - - constructor(private readonly endpoint: string) {} - - push(item: unknown): void { - if (this.closed) throw new Error(`client api: ${this.endpoint} stream has terminated`) - if (this.ended) throw new Error(`client api: ${this.endpoint} uplink was ended`) - this.items.pushBack(item) - this.signal() - } - - end(): void { - if (this.ended || this.closed) return - this.ended = true - this.signal() - } - - /** The carrier stopped reading: the logical stream terminated or was disposed. Idempotent. */ - close(): void { - if (this.closed) return - this.closed = true - this.items.clear() - this.signal() - } - - [Symbol.asyncIterator](): AsyncIterator { - return this - } - - async next(): Promise> { - while (true) { - if (this.closed) return UPLINK_DONE - if (this.items.size > 0) return { value: this.items.popFront(), done: false } - if (this.ended) return UPLINK_DONE - await new Promise((resolve) => { this.wake = resolve }) - } - } - - return(): Promise> { - this.close() - return Promise.resolve(UPLINK_DONE) - } - - private signal(): void { - const wake = this.wake - this.wake = undefined - wake?.() - } -} - type InvokeRemote = ( direct: DirectMethod | undefined, scoped: ScopedMethod | undefined, diff --git a/packages/api/gateway/src/client/stream-client.ts b/packages/api/gateway/src/client/stream-client.ts index b67918995e..7a580c5fac 100644 --- a/packages/api/gateway/src/client/stream-client.ts +++ b/packages/api/gateway/src/client/stream-client.ts @@ -31,10 +31,18 @@ interface SocketWaiter { interface UplinkPump { /** Settles once the pump has stopped sending. */ readonly done: Promise - /** Interrupt the pump, including a read blocked on the caller's iterator. */ + /** Interrupt the pump, including a read blocked on the caller's iterator, and release the iterator. */ stop(): void } +/** One open logical stream: its downlink frames and, once the `open` frame is out, its uplink pump. */ +interface LogicalStream { + readonly inbox: StreamInbox + pump: UplinkPump | undefined +} + +const UPLINK_DONE: IteratorReturnResult = { value: undefined, done: true } + /** * Keep one physical WebSocket and share it among independently cancellable * Remote streams. A carrier that supplies an in-process stream opener never @@ -45,7 +53,7 @@ export class RemoteStreamMuxClient { private cancelCandidate: ((error: Error) => void) | undefined private keepAlive: Promise | undefined private revision = 0 - private readonly streams = new Map() + private readonly streams = new Map() private readonly waiters = new Set() private running = false private disposed = false @@ -97,20 +105,20 @@ export class RemoteStreamMuxClient { signal.throwIfAborted() const streamId = randomUUID() const inbox = new StreamInbox() + const stream: LogicalStream = { inbox, pump: undefined } let carrier: WebSocket | undefined let opened = false let terminal = false - let pump: UplinkPump | undefined const abort = (): void => { inbox.fail(signal.reason) } signal.addEventListener('abort', abort, { once: true }) try { const socket = await this.waitForSocket(signal) signal.throwIfAborted() carrier = socket - this.streams.set(streamId, inbox) + this.streams.set(streamId, stream) this.send(socket, { type: 'open', streamId, endpoint, payload }) opened = true - if (uplink !== undefined) pump = this.pumpUplink(socket, streamId, uplink, signal, inbox) + if (uplink !== undefined) stream.pump = this.pumpUplink(socket, streamId, uplink, signal, inbox) while (true) { const frame = await inbox.next() signal.throwIfAborted() @@ -127,20 +135,21 @@ export class RemoteStreamMuxClient { } finally { signal.removeEventListener('abort', abort) this.streams.delete(streamId) - pump?.stop() + stream.pump?.stop() if (opened && !terminal && carrier?.readyState === WebSocket.OPEN) { this.send(carrier, { type: 'cancel', streamId }) } // The old generation's pump has stopped before a supervisor reopens the next one. - if (pump !== undefined) await pump.done + if (stream.pump !== undefined) await stream.pump.done } } /** * Send the caller's uplink items on this generation's socket. `stop()` - * interrupts a pump blocked on `uplink.next()`; the uplink iterator's - * `return()` is invoked without being awaited because a generator blocked in - * `next()` only completes it once it yields. + * interrupts a pump blocked on `uplink.next()` and releases the iterator: a + * handle's queue closes at once, so `send()` throws from then on, and any + * other iterator's `return()` is invoked without being awaited because a + * generator blocked in `next()` only completes it once it yields. */ private pumpUplink( socket: WebSocket, @@ -152,14 +161,20 @@ export class RemoteStreamMuxClient { const stopped = Promise.withResolvers>() const interruption: IteratorReturnResult = { value: undefined, done: true } const uplinkIterator = uplink[Symbol.asyncIterator]() + const state = { stopping: false, exhausted: false, released: false } + const release = (): void => { + if (state.released || state.exhausted) return + state.released = true + if (uplink instanceof ClientUplinkQueue) uplink.close() + void Promise.resolve().then(() => uplinkIterator.return?.()).catch(() => undefined) + } const done = (async (): Promise => { - let exhausted = false try { while (true) { const next = await Promise.race([uplinkIterator.next(), stopped.promise]) - if (next === interruption || signal.aborted || this.socket !== socket) return + if (state.stopping || next === interruption || signal.aborted || this.socket !== socket) return if (next.done === true) { - exhausted = true + state.exhausted = true break } this.send(socket, { type: 'item', streamId, value: next.value }) @@ -168,14 +183,17 @@ export class RemoteStreamMuxClient { } catch (error) { inbox.fail(error) } finally { - if (!exhausted) { - void Promise.resolve().then(() => uplinkIterator.return?.()).catch(() => undefined) - } + release() } })() return { done, - stop: () => { stopped.resolve(interruption) }, + stop: () => { + if (state.stopping) return + state.stopping = true + stopped.resolve(interruption) + release() + }, } } @@ -284,7 +302,11 @@ export class RemoteStreamMuxClient { try { if (typeof data !== 'string') throw new Error('api gateway: Remote stream WebSocket requires text messages') const frame = parseRemoteStreamServerMessage(data) - this.streams.get(frame.streamId)?.push(frame) + const stream = this.streams.get(frame.streamId) + if (stream === undefined) return + stream.inbox.push(frame) + // A terminal frame ends the uplink now, not on the consumer's next read. + if (frame.type !== 'item') stream.pump?.stop() } catch (error) { const failure = new RemoteStreamCarrierError('api gateway: invalid Remote stream frame', { cause: error }) this.failAll(failure) @@ -324,7 +346,10 @@ export class RemoteStreamMuxClient { } private failAll(error: unknown): void { - for (const stream of this.streams.values()) stream.fail(error) + for (const stream of this.streams.values()) { + stream.inbox.fail(error) + stream.pump?.stop() + } } private send(socket: WebSocket, message: RemoteStreamClientMessage): void { @@ -361,6 +386,83 @@ class StreamInbox { } } +/** + * Uplink items a stream handle queues for its carrier: the mux pump or the + * in-process Host decoder iterates it as the stream's uplink. `end()` is the + * Client half-close; `close()` marks the stream terminated, after which + * `push()` throws. One consumer reads it, one read at a time. + */ +export class ClientUplinkQueue implements AsyncIterable, AsyncIterator { + private readonly items = new Deque() + private ended = false + private closed = false + private wake: (() => void) | undefined + + /** @param endpoint - canonical Remote endpoint named by failures. */ + constructor(private readonly endpoint: string) {} + + /** + * Queue one item for the carrier. + * @param item - item the Host validates against the method's uplink codec. + * @throws {Error} after `end()` or once the stream has terminated. + */ + push(item: unknown): void { + if (this.closed) throw new Error(`client api: ${this.endpoint} stream has terminated`) + if (this.ended) throw new Error(`client api: ${this.endpoint} uplink was ended`) + this.items.pushBack(item) + this.signal() + } + + /** Half-close: the carrier reads the queued items, then `end`. Idempotent; ignored after termination. */ + end(): void { + if (this.ended || this.closed) return + this.ended = true + this.signal() + } + + /** The carrier stopped reading: the logical stream terminated or was disposed. Idempotent. */ + close(): void { + if (this.closed) return + this.closed = true + this.items.clear() + this.signal() + } + + [Symbol.asyncIterator](): AsyncIterator { + return this + } + + /** + * Take the next queued item, waiting for one; ends after `end()` or `close()`. + * @returns the next item, or the end of the uplink. + * @throws {Error} when a read is already pending. + */ + async next(): Promise> { + while (true) { + if (this.closed) return UPLINK_DONE + if (this.items.size > 0) return { value: this.items.popFront(), done: false } + if (this.ended) return UPLINK_DONE + if (this.wake !== undefined) throw new Error(`client api: ${this.endpoint} uplink has one pending read`) + await new Promise((resolve) => { this.wake = resolve }) + } + } + + /** + * The carrier is done with the uplink: close it. + * @returns the end of the uplink. + */ + return(): Promise> { + this.close() + return Promise.resolve(UPLINK_DONE) + } + + private signal(): void { + const wake = this.wake + this.wake = undefined + wake?.() + } +} + function remoteStreamUrl(): string { // The mux route is registered absolute; a page resolves its document-relative // form against its own document base. A shell-owned Host on another origin diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index ff714d8fce..46ac166101 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto' import { Context, Service, symbols } from '@deepseek-ai/cordis' -import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection' +import { OperatorPeer, type ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection' import { Deque } from '@deepseek-ai/dsh-deque' import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' @@ -19,7 +19,6 @@ import { remoteMethods, type InvocationDescriptor, type InvocationParameterDescriptor, - type PeerId, type PeerScope, type RemoteInvocation, type TypertCodec, @@ -223,10 +222,6 @@ export class TypertGatewayService extends Service implements TypertGateway { ctx.on('internal/service', () => { this.srcClaims = undefined }) - // Outside a Remote call `ctx.invocation` reads as undefined instead of the - // reflect service's "cannot get property" error; a call-derived Context - // shadows this accessor with its own property. - ctx.accessor('invocation', { get: () => undefined }) ctx.inject(['connection'], (connectionCtx) => { connectionCtx.connection.rpc.intercept( '/api', @@ -366,6 +361,7 @@ export class TypertGatewayService extends Service implements TypertGateway { private async openStream(request: InvokeRemoteRequest, control: AbortController): Promise> { const prepared = await this.prepareInvocation(request, control) if (prepared.descriptor.mode === undefined) { + await prepared.invocation.close() throw new TypertGatewayError( 'gateway/signature-invalid', prepared.endpoint, @@ -430,18 +426,14 @@ export class TypertGatewayService extends Service implements TypertGateway { /** * The Peer an in-process carrier speaks for when it names none: the - * operator's Peer when Connection is mounted, otherwise a Gateway-local - * stand-in that the Gateway treats the same way. + * operator's Peer when Connection is mounted, otherwise an operator scope the + * Gateway owns for its own lifetime. * @returns the operator Peer. */ private operatorPeer(): PeerScope { const connection = this.ctx.get('connection') if (connection !== undefined) return connection.operator - this.inProcessOperator ??= { - id: 'in-process-operator' as PeerId, - ctx: this.ctx, - dispose: () => Promise.resolve(), - } + this.inProcessOperator ??= new OperatorPeer(this.ctx) return this.inProcessOperator } @@ -1098,8 +1090,10 @@ async function *cancellableStream( } } finally { signal.removeEventListener('abort', onAbort) - await iterator.return?.() + // The uplink closes first so a method blocked on `uplink.next()` unwinds + // before its iterator is asked to return. await invocation.close() + await iterator.return?.() } } @@ -1152,8 +1146,9 @@ class UplinkDecoder implements AsyncIterable, AsyncIterator { } async next(): Promise> { - if (this.closed) return UPLINK_DONE + // A cancelled stream reports the cancellation on every read, closed or not. if (this.signal.aborted) throw streamAbortFailure(this.endpoint, this.signal.reason) + if (this.closed) return UPLINK_DONE const next = await Promise.race([this.source.next(), this.interrupted.promise]) if (next.done === true) { this.finish() diff --git a/packages/api/gateway/src/stream-server.ts b/packages/api/gateway/src/stream-server.ts index bf754c6a92..9454f03f2d 100644 --- a/packages/api/gateway/src/stream-server.ts +++ b/packages/api/gateway/src/stream-server.ts @@ -70,16 +70,14 @@ export class RemoteStreamMuxServer { */ handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer, peer: PeerScope): void { this.server.handleUpgrade(req, socket, head, (websocket) => { + const release = bindPeer(websocket, peer) + if (release === undefined) return this.missedHeartbeats.set(websocket, 0) websocket.on('pong', () => { this.missedHeartbeats.set(websocket, 0) }) this.startHeartbeat() const bound: BoundStreamOpener = (endpoint, payload, uplink, control) => this.open(endpoint, payload, uplink, peer, control) const connection = new RemoteStreamMuxConnection(websocket, bound, this.failure, this.streamInboxBytes) - const release = peer.ctx.effect( - () => () => { websocket.close(1001, 'peer left') }, - 'api-gateway: Remote stream socket bound to its Peer', - ) const done = connection.run() this.connections.add(done) void done.then(() => { @@ -167,6 +165,12 @@ class RemoteStreamMuxConnection { await Promise.all(active.map(stream => stream.done)) } + /** + * Dispatch one frame. `item`, `end`, and `cancel` for a stream this connection + * no longer owns are dropped: a finished stream leaves the table while the + * Client's in-flight frames are still arriving. A duplicate `open` is the one + * protocol violation that closes the socket. + */ private receive(text: string): void { const message = parseRemoteStreamClientMessage(text) switch (message.type) { @@ -175,11 +179,11 @@ class RemoteStreamMuxConnection { return } case 'item': { - this.requireStream(message.streamId).inbox.push(message.value, Buffer.byteLength(text, 'utf8')) + this.streams.get(message.streamId)?.inbox.push(message.value, Buffer.byteLength(text, 'utf8')) return } case 'end': { - this.requireStream(message.streamId).inbox.end() + this.streams.get(message.streamId)?.inbox.end() return } case 'cancel': { @@ -194,15 +198,6 @@ class RemoteStreamMuxConnection { } } - /** Uplink frames for an id this connection does not own are a protocol violation. */ - private requireStream(streamId: string): ActiveStream { - const active = this.streams.get(streamId) - if (active === undefined) { - throw new Error(`api gateway: unknown Remote stream id ${JSON.stringify(streamId)}`) - } - return active - } - private openStream(message: Extract): void { if (this.streams.has(message.streamId)) { throw new Error(`api gateway: duplicate Remote stream id ${JSON.stringify(message.streamId)}`) @@ -407,6 +402,23 @@ class UplinkInbox implements AsyncIterable, AsyncIterator { } } +/** + * Close the socket when the Peer's scope is disposed. A scope that is already + * disposed leaves no Peer for the socket to speak for, so the socket closes now. + * @returns the registration's disposer, or `undefined` when the socket was closed. + */ +function bindPeer(websocket: WebSocket, peer: PeerScope): (() => unknown) | undefined { + try { + return peer.ctx.effect( + () => () => { websocket.close(1001, 'peer left') }, + 'api-gateway: Remote stream socket bound to its Peer', + ) + } catch { + websocket.close(1001, 'peer left') + return undefined + } +} + function rawText(data: RawData): string { if (Array.isArray(data)) return Buffer.concat(data).toString('utf8') if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8') diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index 0a40849b66..ee90ce9ab6 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -2,14 +2,14 @@ import { randomUUID } from 'node:crypto' import { once } from 'node:events' import { afterEach, describe, expect, it, vi } from 'vitest' import WebSocket, { type RawData } from 'ws' -import { Context, Service, symbols } from '@deepseek-ai/cordis' +import { Context, symbols } from '@deepseek-ai/cordis' import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' import WebServer from '@deepseek-ai/dsh-host-webserver' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { - bindTypertRemote, Remote, remoteErrorOf, + TypertRemoteService, type InvocationDescriptor, type PeerScope, type RemoteInvocation, @@ -50,8 +50,7 @@ const REMOTE_HOST = { home: '/home/fixture' } as const type AgentWireId = TypertContextWire const agentId = (value: string): AgentWireId => value as AgentWireId -class FeedService extends Service { - readonly typertRemote = bindTypertRemote(this, 'feed') +class FeedService extends TypertRemoteService { readonly signals: AbortSignal[] = [] readonly peeked: unknown[] = [] readonly peers: PeerScope[] = [] @@ -167,6 +166,13 @@ class FeedService extends Service { this.peeked.push(await pending, await iterator.next().then(result => result, (error: unknown) => error)) } + /** Yields once, then blocks on the uplink until the Gateway closes it. */ + @Remote({ mode: 'stream' }) + async *drain(): RemoteStream { + yield 'ready' + for await (const item of this.invocation().uplink()) yield item + } + /** Reports the call context; its descriptor declares no uplink codec, so items arrive as `unknown`. */ @Remote({ mode: 'stream' }) async *context(label: string): RemoteStream { @@ -407,7 +413,8 @@ describe('Typert Remote streams', () => { await expect(pending).rejects.toThrow('Remote invocation "feed/hold" was aborted') const leftover = service.leftover if (leftover === undefined) throw new Error('fixture did not retain its uplink iterator') - await expect(leftover.next()).resolves.toEqual({ done: true, value: undefined }) + // The unread item is gone; a cancelled stream reports the cancellation on every later read. + await expect(leftover.next()).rejects.toMatchObject({ code: 'gateway/cancelled' }) await expect(leftover.return?.()).resolves.toEqual({ done: true, value: undefined }) }) @@ -421,7 +428,7 @@ describe('Typert Remote streams', () => { 'raw:{"nested":true}', 'typert gateway: feed/context: invocation.uplink() is available once per call', ]) - expect(service.peers.at(-1)?.id).toBe('in-process-operator') + expect(service.peers.at(-1)?.id).toMatch(/^[0-9a-f-]{36}$/) expect(service.invocationOutsideCall()).toBeUndefined() await expect(collect(await ctx.typertGateway.stream({ namespace: 'feed', method: 'context', args: { label: 'again' }, @@ -466,6 +473,28 @@ describe('Typert Remote streams', () => { await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) }) + it('closes the uplink before returning a method blocked on it', async () => { + const { ctx } = await setup(false) + const source = await ctx.typertGateway.stream({ + namespace: 'feed', method: 'drain', args: {}, uplink: neverYielding(), + }) + const iterator = source[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'ready' }) + await expect(iterator.return?.()).resolves.toEqual({ done: true, value: undefined }) + }) + + it('releases the uplink when a unary method is opened through the stream carrier', async () => { + const { ctx } = await setup(false) + const returned = vi.fn(async (): Promise> => ({ value: undefined, done: true })) + const unread: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ next: () => new Promise>(() => {}), return: returned }), + } + await expect(ctx.typertGateway.stream({ + namespace: 'feed', method: 'unary', args: { label: 'a' }, uplink: unread, + })).rejects.toMatchObject({ code: 'gateway/signature-invalid' }) + await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) + }) + it('lets a unary method read uplink items while it runs', async () => { const { ctx } = await setup(false) await expect(ctx.typertGateway.invoke({ @@ -1289,6 +1318,7 @@ function descriptors(): InvocationDescriptor[] { withUplink('ignore', [prefix], false), withUplink('hold', []), withUplink('peek', []), + withUplink('drain', [], false), stream('context', [label], z.string()), { ...stream('follow', [label], z.string()), cancellation: { parameter: 'signal' } }, stream('sync', [label], z.string()), diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index 06c0e93639..14b6cbb7c1 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -26,6 +26,7 @@ import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { ClientRemote } from '../src/client/index.ts' import { apply, inject, RemoteStream } from '../src/client/index.ts' import { + ClientUplinkQueue, RemoteStreamCarrierError, RemoteStreamMuxClient, } from '../src/client/stream-client.ts' @@ -2576,6 +2577,89 @@ describe('Client Typert API', () => { }) }) + it('sends cancel on dispose while nobody reads, and drops what was buffered', async () => { + await withFakeWebSocket('https://harness.example/', async () => { + const ctx = await bench(vi.fn(), 'web') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const handle = ctx.remote.probe.attach('alpha') + const iterator = handle[Symbol.asyncIterator]() + const socket = FakeWebSocket.sockets[0]! + await vi.waitFor(() => { expect(socket.sent).toHaveLength(1) }) + const opened = JSON.parse(socket.sent[0]!) as { streamId: string } + socket.receive({ type: 'item', streamId: opened.streamId, value: 'alpha:1' }) + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'alpha:1' }) + // No read is pending: the cancel still goes out on dispose, and the buffered item is dropped. + socket.receive({ type: 'item', streamId: opened.streamId, value: 'alpha:2' }) + handle.dispose() + await vi.waitFor(() => { + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'cancel']) + }) + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + expect(() => { handle.send('late') }).toThrow('client api: probe/attach stream has terminated') + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + }) + + it('ends the uplink the moment a terminal frame arrives, before the consumer reads it', async () => { + await withFakeWebSocket('https://harness.example/', async () => { + const ctx = await bench(vi.fn(), 'web') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const handle = ctx.remote.probe.attach('alpha') + handle.send('a') + const socket = FakeWebSocket.sockets[0]! + await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) }) + const opened = JSON.parse(socket.sent[0]!) as { streamId: string } + socket.receive({ type: 'end', streamId: opened.streamId }) + expect(() => { handle.send('after end') }).toThrow('client api: probe/attach stream has terminated') + handle.end() + await expect(drainStream(handle)).resolves.toEqual([]) + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'item']) + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + }) + + it('refuses an uplink item that is not a lossless JSON value', async () => { + const mock = RemoteMock.create().stream('probe/attach', async (args, stream) => { + const [{ topic }] = args as [{ readonly topic: string }] + for await (const item of stream.uplink) stream.push(`${topic}:${String(item)}`) + stream.end() + }) + const { ctx, client } = await benchFiber(vi.fn(), 'in-process', mock.rpc.open) + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const handle = ctx.remote.probe.attach('alpha') + expect(() => { handle.send(1n as unknown as string) }).toThrow('client api: probe/attach uplink item is not a lossless JSON value') + expect(() => { handle.send(Number.NaN as unknown as string) }).toThrow('is not a lossless JSON value') + handle.send('ok') + handle.end() + await expect(drainStream(handle)).resolves.toEqual(['alpha:ok']) + } finally { + await dispose() + await client.dispose() + } + }) + + it('lets one uplink read wait at a time', async () => { + const queue = new ClientUplinkQueue('probe/attach') + const first = queue.next() + await expect(queue.next()).rejects.toThrow('client api: probe/attach uplink has one pending read') + queue.push('x') + await expect(first).resolves.toEqual({ value: 'x', done: false }) + queue.end() + await expect(queue.next()).resolves.toEqual({ value: undefined, done: true }) + expect(() => { queue.push('late') }).toThrow('client api: probe/attach uplink was ended') + await expect(queue.return()).resolves.toEqual({ value: undefined, done: true }) + expect(() => { queue.push('closed') }).toThrow('client api: probe/attach stream has terminated') + }) + it('sends cancel and fails the downlink when the caller signal aborts', async () => { await withFakeWebSocket('https://harness.example/', async () => { const ctx = await bench(vi.fn(), 'web') diff --git a/packages/api/gateway/tests/stream-server.host.spec.ts b/packages/api/gateway/tests/stream-server.host.spec.ts index 6a8a2fdb00..469aa25b0c 100644 --- a/packages/api/gateway/tests/stream-server.host.spec.ts +++ b/packages/api/gateway/tests/stream-server.host.spec.ts @@ -258,6 +258,21 @@ describe('Remote stream mux server carrier lifecycle', () => { }) describe('Remote stream mux server Peer binding', () => { + it('closes an upgrade at once when the admitted Peer scope is already disposed', async () => { + const peer = await fixturePeer() + await peer.dispose() + const opened = vi.fn() + const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => { + opened() + return waitForAbort(control.signal) + }, 2_000, 262_144, peer) + const client = new WebSocket(entry.url) + const closeEvent = await once(client, 'close') + expect(closeEvent[0]).toBe(1001) + expect(String(closeEvent[1])).toBe('peer left') + expect(opened).not.toHaveBeenCalled() + }) + it('hands the admitted Peer to every opener and closes the socket when its scope is disposed', async () => { const peer = await fixturePeer() const seen: PeerScope[] = [] @@ -370,16 +385,21 @@ describe('Remote stream mux server uplink', () => { await once(client, 'close') }) - it('closes the socket for uplink frames naming an unknown stream', async () => { - const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => waitForAbort(control.signal)) - for (const frame of [itemFrame('absent', 1), endFrame('absent')]) { - const client = await connect(entry.url) - const closed = once(client, 'close') - client.send(frame) - const closeEvent = await closed - expect(closeEvent[0]).toBe(1008) - expect(String(closeEvent[1])).toBe('invalid Remote stream request') - } + it('drops uplink frames for a stream it no longer owns and keeps the socket open', async () => { + const entry = await startMux(async (endpoint, _payload, uplink, _peer, control) => + endpoint === 'fixture/echo' ? echoUplink(uplink) : waitForAbort(control.signal)) + const client = await connect(entry.url) + const frames = collectFrames(client) + const received = countMessages(acceptedSocket(entry.mux)) + client.send(itemFrame('absent', 1)) + client.send(endFrame('absent')) + client.send(openFrame('live', 'fixture/echo')) + client.send(itemFrame('live', 'served')) + await vi.waitFor(() => { expect(received.count).toBe(4) }) + await vi.waitFor(() => { expect(frames).toEqual([{ type: 'item', streamId: 'live', value: 'served' }]) }) + expect(client.readyState).toBe(WebSocket.OPEN) + client.close() + await once(client, 'close') }) it('ends a pending uplink read when the socket closes', async () => { diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 60b9042dc5..ddf0603df0 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/connection/README.md -README.md: 48d54096ceb0597c166c26010aec60f713b8d696 -README.zh.md: 40f0c267dfd9ed58ccfe3ef5c23b3f001eef28f6 +README.md: 6c49836b0e581666db0df503b175132ed1e6295b +README.zh.md: 5843efd2299526cf00b0c6331668c304d6042f56 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 48d54096ce..6c49836b0e 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -40,7 +40,7 @@ The cookie signing secret is the owner-scoped `client-connection/browser-session Before authentication, every request still passes `src/api-request-trust.ts`. Its `Host` must be loopback or match a `trustedHosts` entry: exact on `host:port`, any port on port-less entries, both sides WHATWG-normalized. An attached `Origin` must equal that Host and `sec-fetch-site: cross-site` is refused. Malformed configured authorities fail plugin load. These checks defend DNS rebinding and cross-site browser requests; they never establish identity. A failed Host/Origin check returns 403, while a trusted but unauthenticated request returns 401. `dsh web --host 0.0.0.0` remains unsupported. Decision records: [browser request trust](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md) and [browser token authentication](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md). -Every admitted request speaks for one Peer, the operator. `ctx.connection.operator` is that `PeerScope`: its `ctx` is a Cordis scope that owns connection-lifetime registrations and is disposed with the Connection. `ctx.connection.admit(request)` runs the trust and authentication checks and answers with the rejection status or the operator; the `/api` route and the Gateway's WebSocket upgrade admit through it, and every RPC handler receives the Peer of its call. +Every admitted request speaks for one Peer, the operator. `ctx.connection.operator` is that `PeerScope`: its `ctx` is a Cordis scope that owns connection-lifetime registrations and is disposed with the Connection. `ctx.connection.admit(request)` runs the trust and authentication checks and answers with the rejection status or the operator; the `/api` route and the Gateway's WebSocket upgrade admit through it, and every RPC handler receives the Peer of its call. `OperatorPeer` is exported so a composition without Connection, such as the Gateway's in-process carrier, owns an operator scope with the same contract. Authenticated shared HTTP requests pass through the `connection/request` waterfall before body transfer. A listener may refuse new requests or await `next()` through response completion; removing its owning fiber removes admission behavior. Desktop uses this hook to lock new API work during an approved installation without canceling already-admitted work. WebSocket stream ownership remains with API Gateway. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 40f0c267df..5843efd229 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -40,7 +40,7 @@ cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-sessi 认证之前,每个请求仍经过 `src/api-request-trust.ts`。其 `Host` 必须是 loopback,或与 `trustedHosts` 条目匹配:带端口的 `host:port` 精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化。若附带 `Origin`,它必须等于该 Host;`sec-fetch-site: cross-site` 一律拒绝。畸形配置 authority 会让插件加载失败。这些检查防御 DNS rebinding 与跨站浏览器请求,绝不建立身份。Host/Origin 校验失败返回 403;Host 可信但未认证的请求返回 401。`dsh web --host 0.0.0.0` 仍不受支持。决策记录:[浏览器请求信任](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)与[浏览器令牌认证](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md)。 -每个被接纳的请求都代表同一个 Peer——操作者。`ctx.connection.operator` 就是这个 `PeerScope`:其 `ctx` 是拥有连接期注册的 Cordis scope,随 Connection 一起释放。`ctx.connection.admit(request)` 执行信任与认证检查,以拒绝状态或操作者作答;`/api` 路由与 Gateway 的 WebSocket 升级都经它接纳,每个 RPC 处理器都收到本次调用的 Peer。 +每个被接纳的请求都代表同一个 Peer——操作者。`ctx.connection.operator` 就是这个 `PeerScope`:其 `ctx` 是拥有连接期注册的 Cordis scope,随 Connection 一起释放。`ctx.connection.admit(request)` 执行信任与认证检查,以拒绝状态或操作者作答;`/api` 路由与 Gateway 的 WebSocket 升级都经它接纳,每个 RPC 处理器都收到本次调用的 Peer。`OperatorPeer` 对外导出,供没有 Connection 的组合(例如 Gateway 的进程内载体)以同一约定拥有一个操作者 scope。 通过认证的共享 HTTP 请求在传输请求体之前经过 `connection/request` waterfall。监听器可以拒绝新请求,或等待 `next()` 直到响应完成;释放所属 fiber 会移除准入行为。Desktop 使用此扩展点,在已批准的安装期间锁住新的 API 工作,而不取消已接纳的工作。WebSocket 流仍由 API Gateway 负责。 diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 5aeed85337..c76ce937c8 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -36,6 +36,7 @@ export type { } from './rpc.ts' export type { PeerId, PeerScope, RemoteInvocation } from '@deepseek-ai/dsh-typert-protocol' export { RpcId, transportError } from './rpc.ts' +export { OperatorPeer } from './operator-peer.ts' export { clientRequestSchema, rpcErrorSchema, diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml index d6fa266c66..96ccbc1151 100644 --- a/packages/experimental/webworker-runtime/README.i18n.yaml +++ b/packages/experimental/webworker-runtime/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/experimental/webworker-runtime/README.md -README.md: ce98accac7abb2241f79b16e596e68e1d1fbdd95 -README.zh.md: cc3cf583bb77ed0d3e9611f8d8543f39b9ea9d18 +README.md: f4e412e25e70fe74d66018bf3a0cb66a550603e4 +README.zh.md: 5ae13f0ae49961cfe67d5b567999f500fe7f6f7a diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md index ce98accac7..f4e412e25e 100644 --- a/packages/experimental/webworker-runtime/README.md +++ b/packages/experimental/webworker-runtime/README.md @@ -30,7 +30,7 @@ Three build artifacts and one source-owned process layer: - **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the base image and any ordered data overlays (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. Overlays may replace files only under `home/` and `workspace/`; they cannot replace the base manifest, configuration, or modules. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`. - **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. The `node:util` JSON-data comparator compares own enumerable properties, preserving Node admission decisions for recorded fields such as `__proto__`. The global `process` shim carries Node detection fields including `title`, preventing Worker execution from entering DOM-only branches. The pack-time parser reports statically named module requests, including module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named `node:module` or `module` import, to the packer's reachability walk. Stored, CommonJS-obtained, and rebased `createRequire` calls require image entry seeds. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink, `FileHandle.stat({ bigint: true })` reports the same device and inode identity as a path stat while the name still refers to that file, and `FileHandle.chmod()` updates the opened file identity; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount. The callback `fs.realpath.native` alias resolves within the mounted VFS and supports `util.promisify`. - **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process. -- **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` fetches each combo on first demand, embeds its tunnel-only source map as a Base64 data URL, and executes the script as a Blob. The tunnel also exposes fetch-shaped transport, the independent file-upload carrier, and the API client. Request frames preserve Blob bodies through structured clone and transfer `ReadableStream` ownership. The Host Worker streams both forms into the route, so neither browser thread creates a complete byte array for a generic-file upload. +- **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` fetches each combo on first demand, embeds its tunnel-only source map as a Base64 data URL, and executes the script as a Blob. The tunnel also exposes fetch-shaped transport, the independent file-upload carrier, and the API client. Request frames preserve Blob bodies through structured clone and transfer `ReadableStream` ownership. The Host Worker streams both forms into the route, so neither browser thread creates a complete byte array for a generic-file upload. A Remote stream's uplink crosses as `stream-uplink-item` / `stream-uplink-end` frames into an unbounded `TunnelUplink` that drops items arriving after `end`: the page and its worker are one trusted same-origin boundary, so the Host inbox cap does not apply. Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the pre-boot chooser plus Worker activation in headless Chromium. The empty selection exercises first-run startup. The `vfs-example` overlay supplies ordinary workspace files and plaintext persistence artifacts for cold Workspace/Session discovery, tool presentation, subagent navigation, and history paging without a model request. The [packer](../webworker-packer/README.md) prepares current-generation successors from committed historical logs in Node; those source logs remain byte-identical in the overlay. The fixture generator owns the acceptance test's current projection cache. The chooser reserves WebFS as a separate user-authorized source; that provider does not read the built-in fixture. diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md index cc3cf583bb..5ae13f0ae4 100644 --- a/packages/experimental/webworker-runtime/README.zh.md +++ b/packages/experimental/webworker-runtime/README.zh.md @@ -30,7 +30,7 @@ kind: "package-library" - **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务 seam 交给隧道。Overlay 只能替换 `home/` 与 `workspace/` 下的文件,不能替换基础 manifest、配置或模块。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。 - **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。`node:util` 的 JSON 数据比较器检查可枚举的自有属性,使 `__proto__` 等记录字段的准入判定与 Node 一致。全局 `process` shim 带有包括 `title` 在内的 Node 环境识别字段,避免 Worker 执行误入仅适用于 DOM 的分支。pack 期解析器会把名称静态可知的模块请求报告给 packer 的可达性遍历,其中包括通过 `node:module` 或 `module` 具名导入在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用。保存、经 CommonJS 获取或另设基准的 `createRequire` 调用需要镜像入口种子。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式,只要文件名仍指向该文件,`FileHandle.stat({ bigint: true })` 报告的 device 与 inode 身份就与路径 stat 相同,`FileHandle.chmod()` 则更新打开文件身份的权限;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒。回调式 `fs.realpath.native` 别名在已挂载的 VFS 内解析路径,并支持 `util.promisify`。 - **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。 -- **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时获取 combo、把仅 tunnel 可达的 sourcemap 内嵌为 Base64 data URL,再以 Blob 执行脚本。Tunnel 还暴露 fetch 形式的传输、独立文件上传载体和 API 客户端。请求帧通过结构化克隆保留 Blob 请求体,并转移 `ReadableStream` 的所有权。Host Worker 将两种请求体都逐块送入路由,因此通用文件上传不会在任何浏览器线程创建完整字节数组。 +- **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时获取 combo、把仅 tunnel 可达的 sourcemap 内嵌为 Base64 data URL,再以 Blob 执行脚本。Tunnel 还暴露 fetch 形式的传输、独立文件上传载体和 API 客户端。请求帧通过结构化克隆保留 Blob 请求体,并转移 `ReadableStream` 的所有权。Host Worker 将两种请求体都逐块送入路由,因此通用文件上传不会在任何浏览器线程创建完整字节数组。Remote 流的上行以 `stream-uplink-item` / `stream-uplink-end` 帧进入没有上限的 `TunnelUplink`,`end` 之后到达的项被丢弃:页面与其 worker 是同一个可信的同源边界,Host 的 inbox 上限在此不适用。 验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 pre-boot 选择面板与 Worker 激活。空白选择验证首次启动;`vfs-example` overlay 提供普通 workspace 文件与明文 persistence 产物,无需模型请求即可验证 Workspace/Session 冷发现、工具呈现、subagent 导航和历史分页。[打包器](../webworker-packer/README.zh.md)在 Node 中根据已提交的历史日志准备当前代后继文件;这些源日志在 overlay 中逐字节保持不变。fixture 生成器负责验收测试的当前投影缓存。选择面板为 WebFS 保留独立的用户授权来源;该 provider 不读取内置 fixture。 diff --git a/packages/test-support/remote-mock/README.i18n.yaml b/packages/test-support/remote-mock/README.i18n.yaml index b277653e91..68d36df291 100644 --- a/packages/test-support/remote-mock/README.i18n.yaml +++ b/packages/test-support/remote-mock/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/test-support/remote-mock/README.md -README.md: 1044aeb7207c96441296f9bbaa794902c338f9af -README.zh.md: c3e3818f04dabbeca3163bd342d8dcd919e3dd1b +README.md: f18a4b699e4e535d95fd82309ef0cce8f9aed1ed +README.zh.md: 0c49a5073d826f86cd33e03a27ddf74ba0f86ae1 diff --git a/packages/test-support/remote-mock/README.md b/packages/test-support/remote-mock/README.md index 1044aeb720..f18a4b699e 100644 --- a/packages/test-support/remote-mock/README.md +++ b/packages/test-support/remote-mock/README.md @@ -73,7 +73,7 @@ mock.streams.fail('session/follow', new Error('gone')) await mock.streams.drained('session/follow') ``` -A failed stream rejects the consumer's next read with the given `Error`. Consumer cancellation (the opening signal or an early iterator `return()`) aborts `StreamHandle.signal`, ends the iteration without throwing, and logs the stream as `cancelled`. `StreamHandle.uplink` is the uplink iterable the caller passed with the open through `rpc.open`, or an immediately ended iterable when it passed none; the uplink stays out of the logged args. +A failed stream rejects the consumer's next read with the given `Error`. Consumer cancellation (the opening signal or an early iterator `return()`) aborts `StreamHandle.signal`, ends the iteration without throwing, and logs the stream as `cancelled`. `StreamHandle.uplink` is what the script reads as the Client's uplink: through `rpc.open` it is the carrier's iterable; for a direct `mock.remote..(...)` call it is fed by the returned handle, which is the `RemoteStreamHandle` a generated method returns (iterate it for the downlink, `send()` and `end()` feed the uplink, `dispose()` cancels the stream). The mock function is called with the method's own arguments only, so the uplink never appears in call assertions or logged args. A fake that stands in for a generated stream method returns the `RemoteStreamHandle` the generated method does. `streamHandle(source)` types an `AsyncIterable` as that handle with inert `send`, `end`, and `dispose`; `streamMethod(generator)` lifts an async generator function written for the method's arguments into the method's own signature, for `vi.fn()` and `mockImplementation`. diff --git a/packages/test-support/remote-mock/README.zh.md b/packages/test-support/remote-mock/README.zh.md index c3e3818f04..0c49a5073d 100644 --- a/packages/test-support/remote-mock/README.zh.md +++ b/packages/test-support/remote-mock/README.zh.md @@ -73,7 +73,7 @@ mock.streams.fail('session/follow', new Error('gone')) await mock.streams.drained('session/follow') ``` -失败的流让消费方的下一次读取以给定的 `Error` reject。消费方取消(打开时的 signal 或 iterator 提前 `return()`)会中止 `StreamHandle.signal`、结束迭代而不抛错,并把该流记为 `cancelled`。`StreamHandle.uplink` 是调用方经 `rpc.open` 随打开传入的上行 iterable,未传入时是立即结束的 iterable;上行不进入记录的 args。 +失败的流让消费方的下一次读取以给定的 `Error` reject。消费方取消(打开时的 signal 或 iterator 提前 `return()`)会中止 `StreamHandle.signal`、结束迭代而不抛错,并把该流记为 `cancelled`。`StreamHandle.uplink` 是脚本读到的 Client 上行:经 `rpc.open` 打开时它是载体传入的 iterable;直接调用 `mock.remote..(...)` 时它由返回的句柄喂入——该句柄就是生成方法返回的 `RemoteStreamHandle`(迭代它读下行,`send()` 与 `end()` 喂上行,`dispose()` 取消流)。mock 函数只以方法自己的参数被调用,上行不会出现在调用断言或记录的 args 里。 替代生成流方法的假实现返回生成方法所返回的 `RemoteStreamHandle`。`streamHandle(source)` 把一个 `AsyncIterable` 标注为该句柄,其 `send`、`end`、`dispose` 为空操作;`streamMethod(generator)` 把按方法参数编写的 async generator 函数提升为方法自己的签名,供 `vi.fn()` 与 `mockImplementation` 使用。 diff --git a/packages/test-support/remote-mock/src/index.ts b/packages/test-support/remote-mock/src/index.ts index b6327ba0e2..9ac425a88b 100644 --- a/packages/test-support/remote-mock/src/index.ts +++ b/packages/test-support/remote-mock/src/index.ts @@ -12,5 +12,6 @@ export { RemoteMock, ok } from './remote-mock.ts' export type { OpenStreams, RemoteMockOptions, RemoteTable, StreamFilter, UnaryRuleFn } from './remote-mock.ts' export { frames, openStream, streamHandle, streamMethod } from './streams.ts' export type { StreamHandle, StreamScript } from './streams.ts' +export { MockClientStream } from './streams.ts' export type { LoggedCall, LoggedMiss, LoggedStream, MockLog } from './log.ts' export type { MockedRemote } from './remote-proxy.ts' diff --git a/packages/test-support/remote-mock/src/remote-mock.ts b/packages/test-support/remote-mock/src/remote-mock.ts index 5f44749c01..39d29d5e35 100644 --- a/packages/test-support/remote-mock/src/remote-mock.ts +++ b/packages/test-support/remote-mock/src/remote-mock.ts @@ -3,7 +3,7 @@ import type { ClientConnectionRpc, ConnectionRpcResult } from '@deepseek-ai/dsh-client-connection/client' import { fn, type Mock } from '@vitest/spy' import { MockLogStore, type MockLog } from './log.ts' -import { MockStream, toError, type StreamScript } from './streams.ts' +import { HandleUplink, MockClientStream, MockStream, toError, type StreamScript } from './streams.ts' import { createRemoteProxy, type MockedRemote } from './remote-proxy.ts' /** A unary handler receives the caller's positional arguments, without its trailing `AbortSignal`. */ @@ -116,6 +116,8 @@ export class RemoteMock { private readonly logStore = new MockLogStore() private readonly unaryMocks = new Map>() private readonly streamMocks = new Map>() + /** The carrier's uplink for the open in progress through {@link open}; a direct call has none. */ + private carrierUplink: AsyncIterable | undefined private readonly proxy = createRemoteProxy(endpoint => this.modeOf(endpoint) === 'stream' ? this.streamMock(endpoint) : this.unaryMock(endpoint)) @@ -277,12 +279,15 @@ export class RemoteMock { } /** - * Open through the endpoint's native mock; its default runs the registered script as a controlled stream. - * A native override returns its own iterable: the caller owns consumption and cancellation, outside `OpenStreams`. + * Open through the endpoint's native mock, which is called with the generated + * signature's arguments only; its default runs the registered script as a + * controlled stream. A native override returns its own iterable: the caller + * owns consumption and cancellation, outside `OpenStreams`. * @param endpoint - endpoint. * @param args - positional args. * @param signal - consumer cancellation. - * @param uplink - the stream's uplink, passed positionally after the args as the whole-client proxies send it. + * @param uplink - the carrier's uplink for this open, which the script reads as `StreamHandle.uplink`; + * absent for a direct call, whose returned handle owns the uplink. * @returns the controlled script stream or the native override's caller-owned iterable. * @throws {Error} when the default runs without a registered script (logged as unmatched). */ @@ -292,7 +297,12 @@ export class RemoteMock { signal: AbortSignal, uplink?: AsyncIterable, ): AsyncIterable { - return this.streamMock(endpoint)(...args, ...(uplink === undefined ? [] : [uplink]), signal) as AsyncIterable + this.carrierUplink = uplink + try { + return this.streamMock(endpoint)(...args, signal) as AsyncIterable + } finally { + this.carrierUplink = undefined + } } private streamMock(endpoint: string): Mock { @@ -301,9 +311,7 @@ export class RemoteMock { mock = fn((...values: readonly unknown[]) => { const args = [...values] const signal = args.at(-1) instanceof AbortSignal ? args.pop() as AbortSignal : new AbortController().signal - // Wire args are JSON values, so a trailing async iterable is the stream's uplink. - const uplink = isAsyncIterable(args.at(-1)) ? args.pop() as AsyncIterable : EMPTY_UPLINK - return this.openScript(endpoint, args, signal, uplink) + return this.openScript(endpoint, args, signal, this.carrierUplink) }) this.streamMocks.set(endpoint, mock) } @@ -314,18 +322,26 @@ export class RemoteMock { endpoint: string, args: readonly unknown[], signal: AbortSignal, - uplink: AsyncIterable, - ): AsyncIterable { + carrierUplink: AsyncIterable | undefined, + ): MockClientStream { const script = this.scripts.get(endpoint) if (script === undefined) { this.logStore.miss(endpoint, 'stream') throw new Error(this.noRuleMessage(endpoint)) } + let uplink: AsyncIterable + let owned: HandleUplink | undefined + if (carrierUplink === undefined) { + owned = new HandleUplink() + uplink = owned + } else { + uplink = carrierUplink + } const stream = new MockStream(this.logStore.stream(endpoint, args), signal, uplink) this.live.push(stream) this.wakeOpened(endpoint) stream.run(script, args) - return stream + return new MockClientStream(stream, owned) } /** Throw when any request found no rule, naming the endpoints and the registered ones. */ @@ -364,14 +380,6 @@ function isRuleFn(rule: unknown): rule is UnaryRuleFn { return typeof rule === 'function' } -const EMPTY_UPLINK: AsyncIterable = { - [Symbol.asyncIterator]: () => ({ next: () => Promise.resolve({ value: undefined, done: true }) }), -} - -function isAsyncIterable(value: unknown): value is AsyncIterable { - return typeof value === 'object' && value !== null && Symbol.asyncIterator in value -} - /** A rule's synchronous throw becomes a rejection so the call settles through one path. */ function answerOf(rule: UnaryRuleFn, args: readonly unknown[]): unknown { try { diff --git a/packages/test-support/remote-mock/src/streams.ts b/packages/test-support/remote-mock/src/streams.ts index 12aeb10a99..04b2efdcea 100644 --- a/packages/test-support/remote-mock/src/streams.ts +++ b/packages/test-support/remote-mock/src/streams.ts @@ -45,7 +45,11 @@ export interface StreamHandle { fail(error: Error): void /** Aborts when the opening signal aborts or the consumer returns early. */ readonly signal: AbortSignal - /** Uplink items the caller passed with the open; an immediately ended iterable when it passed none. */ + /** + * Uplink items for the script: the carrier's iterable when the open came + * through `rpc.open`, otherwise what the returned handle's `send()` and + * `end()` feed. + */ readonly uplink: AsyncIterable } @@ -101,7 +105,7 @@ export class MockStream implements StreamHandle, AsyncIterable { /** * @param record - log entry this stream updates. * @param sourceSignal - cancellation from the caller that opened the stream. - * @param uplink - the caller's uplink, exposed to the script unchanged. + * @param uplink - the uplink the script reads. */ constructor( readonly record: StreamRecord, @@ -132,6 +136,11 @@ export class MockStream implements StreamHandle, AsyncIterable { this.settle({ kind: 'fail', error }, 'failed') } + /** The consumer left: cancel the stream and drop what it left. */ + dispose(): void { + this.cancel(new Error(`remote-mock: ${this.record.endpoint} stream disposed`)) + } + /** * Start `script` on this stream. * @param script - the registered script. @@ -226,6 +235,85 @@ export class MockStream implements StreamHandle, AsyncIterable { } } +/** + * The uplink a direct handle owns: `send()` pushes, `end()` half-closes, + * `close()` ends it when the handle is disposed. The script reads it as + * `StreamHandle.uplink`. + */ +export class HandleUplink implements AsyncIterable { + private readonly items: unknown[] = [] + private done = false + private waiter: ((result: IteratorResult) => void) | undefined + + push(item: unknown): void { + if (this.done) throw new Error('remote-mock: uplink was ended') + const waiter = this.waiter + this.waiter = undefined + if (waiter === undefined) this.items.push(item) + else waiter({ value: item, done: false }) + } + + end(): void { + if (this.done) return + this.done = true + this.waiter?.({ value: undefined, done: true }) + this.waiter = undefined + } + + close(): void { + this.items.length = 0 + this.end() + } + + [Symbol.asyncIterator](): AsyncIterator { + return { + next: () => { + if (this.items.length > 0) return Promise.resolve({ value: this.items.shift(), done: false }) + if (this.done) return Promise.resolve({ value: undefined, done: true }) + return new Promise((resolve) => { this.waiter = resolve }) + }, + return: () => { + this.close() + return Promise.resolve({ value: undefined, done: true }) + }, + } + } +} + +/** + * The handle a direct `mock.remote` stream call returns, as a generated method + * would: iterate it for the script's downlink, `send()` and `end()` feed + * `StreamHandle.uplink`, `dispose()` cancels the stream. A stream opened through + * `rpc.open` reads the carrier's uplink instead, and this handle owns none. + */ +export class MockClientStream implements RemoteStreamHandle { + /** + * @param stream - the script-side stream. + * @param uplink - the uplink this handle feeds; absent when the carrier supplied one. + */ + constructor(private readonly stream: MockStream, private readonly uplink: HandleUplink | undefined) {} + + send(item: unknown): void { + if (this.uplink === undefined) { + throw new Error(`remote-mock: ${this.stream.record.endpoint} uplink belongs to the carrier that opened the stream`) + } + this.uplink.push(item) + } + + end(): void { + this.uplink?.end() + } + + dispose(): void { + this.uplink?.close() + this.stream.dispose() + } + + [Symbol.asyncIterator](): AsyncIterator { + return this.stream[Symbol.asyncIterator]() + } +} + /** * The `Error` a thrown value stands for: itself, or a new Error carrying its string form. * @param reason - thrown value. diff --git a/packages/test-support/remote-mock/tests/rpc.client.spec.ts b/packages/test-support/remote-mock/tests/rpc.client.spec.ts index c712f11ac7..da1330df8d 100644 --- a/packages/test-support/remote-mock/tests/rpc.client.spec.ts +++ b/packages/test-support/remote-mock/tests/rpc.client.spec.ts @@ -1,6 +1,6 @@ /** The Connection carrier face: payload forms, streams, unmatched rejections, and abort. */ import { describe, expect, it } from 'vitest' -import { RemoteMock, ok, openStream } from '../src/index.ts' +import { RemoteMock, ok, openStream, type MockClientStream } from '../src/index.ts' const idle = (): AbortSignal => new AbortController().signal @@ -32,9 +32,22 @@ describe('RemoteMock.rpc', () => { await expect(drain(mock.rpc.open!('/api', 'job/attach', { args: ['job-1'] }, idle(), uplink(['a', 'b'])))) .resolves.toEqual(['job-1:a', 'job-1:b']) - await expect(drain(mock.open('job/attach', ['job-2', uplink(['c'])], idle()))).resolves.toEqual(['job-2:c']) - await expect(drain(mock.open('job/attach', ['job-3'], idle()))).resolves.toEqual([]) - expect(mock.log.streams('job/attach').map(open => open.args)).toEqual([['job-1'], ['job-2'], ['job-3']]) + await expect(drain(mock.open('job/attach', ['job-2'], idle(), uplink(['c'])))).resolves.toEqual(['job-2:c']) + // A direct open returns the handle a generated method would: its send/end feed the script's uplink. + const direct = mock.open('job/attach', ['job-3'], idle()) as MockClientStream + direct.send('d') + direct.end() + direct.end() + await expect(drain(direct)).resolves.toEqual(['job-3:d']) + expect(() => { direct.send('late') }).toThrow('remote-mock: uplink was ended') + const disposed = mock.open('job/attach', ['job-4'], idle()) as MockClientStream + disposed.dispose() + await expect(drain(disposed)).resolves.toEqual([]) + expect(mock.log.streams('job/attach').map(open => [open.args, open.state])) + .toEqual([[['job-1'], 'ended'], [['job-2'], 'ended'], [['job-3'], 'ended'], [['job-4'], 'cancelled']]) + const carried = mock.open('job/attach', ['job-5'], idle(), uplink([])) as MockClientStream + expect(() => { carried.send('x') }).toThrow('remote-mock: job/attach uplink belongs to the carrier that opened the stream') + await expect(drain(carried)).resolves.toEqual([]) }) it('rejects malformed payloads and unmatched endpoints, logging the miss', async () => { diff --git a/packages/typert/protocol/README.i18n.yaml b/packages/typert/protocol/README.i18n.yaml index 5b100b5921..679a8d3e38 100644 --- a/packages/typert/protocol/README.i18n.yaml +++ b/packages/typert/protocol/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/typert/protocol/README.md -README.md: f3a758cc9f65ccfff7d5f15af801702359674fa3 -README.zh.md: 4630124ff623ca8223dcae326403c5504db52483 +README.md: fcf9490bb0cece7cdb1264c98de33d22ba52b13c +README.zh.md: 92fa38cda553d668a8898ee4345faea04879eded diff --git a/packages/typert/protocol/README.md b/packages/typert/protocol/README.md index f3a758cc9f..fcf9490bb0 100644 --- a/packages/typert/protocol/README.md +++ b/packages/typert/protocol/README.md @@ -44,7 +44,7 @@ export class GoalService extends TypertRemoteService { Generation turns the method into a wire endpoint under the service's namespace; Clients call it as a typed method through `ctx.remote` (see the [API Gateway reference](../../../docs/api-gateway.md)). A method opts into cooperative cancellation by declaring `signal: AbortSignal` as its final parameter — the signal is injected, never a JSON parameter or lookup field. -A stream method (`@Remote({ mode: 'stream' })`) returns `Iterable`, `AsyncIterable`, or `RemoteStream`. `In` declares the items the Client may send back on the same logical stream; the method reads them through `this.ctx.invocation.uplink()`, and the descriptor carries their codec. `RemoteInvocation` also names the receiving `service`, the calling `peer` (a `PeerScope` the connection layer admitted), and the carrier `signal`; `ctx.invocation` is `undefined` on a Context no Remote call derived. A generated Client stream method returns `RemoteStreamHandle`: the handle with `send`, `end`, and `dispose` beside the downlink iteration. +A stream method (`@Remote({ mode: 'stream' })`) returns `Iterable`, `AsyncIterable`, or `RemoteStream`. `In` declares the items the Client may send back on the same logical stream; the method reads them through `this.ctx.invocation.uplink()`, and the descriptor carries their codec. `RemoteInvocation` also names the receiving `service`, the calling `peer` (a `PeerScope` the connection layer admitted), and the carrier `signal`; `ctx.invocation` is `undefined` on a Context no Remote call derived: the first `TypertRemoteService` constructed in a tree registers that accessor on the root. A generated Client stream method returns `RemoteStreamHandle`: the handle with `send`, `end`, and `dispose` beside the downlink iteration. ### Associating Host objects and Contexts with wire identities diff --git a/packages/typert/protocol/README.zh.md b/packages/typert/protocol/README.zh.md index 4630124ff6..92fa38cda5 100644 --- a/packages/typert/protocol/README.zh.md +++ b/packages/typert/protocol/README.zh.md @@ -44,7 +44,7 @@ export class GoalService extends TypertRemoteService { 生成会把方法变为服务命名空间下的 wire 端点;Client 通过 `ctx.remote` 以类型化方法调用它(见 [API Gateway 参考](../../../docs/api-gateway.zh.md))。方法把 `signal: AbortSignal` 声明为最后一个参数即可选择协作式取消——该信号是注入的,绝不会成为 JSON 参数或查找字段。 -流方法(`@Remote({ mode: 'stream' })`)返回 `Iterable`、`AsyncIterable` 或 `RemoteStream`。`In` 声明 Client 可以在同一条逻辑流上回送的项;方法通过 `this.ctx.invocation.uplink()` 读取它们,描述符携带其 codec。`RemoteInvocation` 还给出接收服务 `service`、发起调用的 `peer`(连接层接纳的一个 `PeerScope`)与载体 `signal`;非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`。生成的 Client 流方法返回 `RemoteStreamHandle`:在下行迭代之外提供 `send`、`end` 与 `dispose` 的句柄。 +流方法(`@Remote({ mode: 'stream' })`)返回 `Iterable`、`AsyncIterable` 或 `RemoteStream`。`In` 声明 Client 可以在同一条逻辑流上回送的项;方法通过 `this.ctx.invocation.uplink()` 读取它们,描述符携带其 codec。`RemoteInvocation` 还给出接收服务 `service`、发起调用的 `peer`(连接层接纳的一个 `PeerScope`)与载体 `signal`;非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`:树中第一个被构造的 `TypertRemoteService` 在根上注册该 accessor。生成的 Client 流方法返回 `RemoteStreamHandle`:在下行迭代之外提供 `send`、`end` 与 `dispose` 的句柄。 ### 把 Host 对象与 Context 关联到 wire identity diff --git a/packages/typert/protocol/src/index.ts b/packages/typert/protocol/src/index.ts index b9acc4888e..8b18229cb4 100644 --- a/packages/typert/protocol/src/index.ts +++ b/packages/typert/protocol/src/index.ts @@ -170,9 +170,21 @@ export abstract class TypertRemoteService extends Service { protected constructor(ctx: Context, serviceKey: string, options: TypertGatewayBindingOptions = {}) { super(ctx, serviceKey) this.typertRemote = bindTypertRemote(this, this.name, options) + provideInvocationAccessor(ctx) } } +/** + * Make `ctx.invocation` read as `undefined` outside a Remote call instead of the + * reflect service's "cannot get property" error; a call-derived Context shadows + * the accessor with its own property. The first Remote Service constructed in a + * tree registers it on the root, where it outlives any one Service. + */ +function provideInvocationAccessor(ctx: Context): void { + if (Object.hasOwn(ctx.root.reflect.props, 'invocation')) return + ctx.root.accessor('invocation', { get: () => undefined }) +} + /** * Mark one public instance method as a direct Remote invocation. * @param _method - decorated method; retained only by the class itself. diff --git a/packages/typert/protocol/tests/protocol.spec.ts b/packages/typert/protocol/tests/protocol.spec.ts index abc919bc82..a8c4049046 100644 --- a/packages/typert/protocol/tests/protocol.spec.ts +++ b/packages/typert/protocol/tests/protocol.spec.ts @@ -253,6 +253,34 @@ describe('typert-protocol Remote declarations', () => { ]) }) + it('registers the invocation accessor once per tree, on the root', async () => { + const root = new Context() + class First extends TypertRemoteService { + constructor(ctx: Context) { + super(ctx, 'first') + } + + outside(): unknown { + return this.ctx.invocation + } + } + class Second extends TypertRemoteService { + constructor(ctx: Context) { + super(ctx, 'second') + } + } + await root.plugin(First) + const second = root.plugin(Second) + await second + const child = root.plugin(() => {}) + await child + expect(child.ctx.invocation).toBeUndefined() + expect((root.get('first') as First).outside()).toBeUndefined() + // The accessor belongs to the root, so a Remote Service leaving does not take it along. + await second.dispose() + expect(child.ctx.invocation).toBeUndefined() + }) + it('rejects malformed decorator calls and targets', () => { const method: (this: object) => void = function (this: object): void {} expect(() => { (Remote as unknown as (value: typeof method) => void)(method) }).toThrow('context is missing') diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts index 9510a65869..57903b0e03 100644 --- a/scripts/package-dependency-policy.ts +++ b/scripts/package-dependency-policy.ts @@ -55,6 +55,7 @@ const SAFE_HOST_DEPENDENCY_EXPORTS = { /** Runtime exports that require every consumer to resolve the provider's shared peer instance. */ const PEER_REQUIRED_HOST_EXPORTS = { + '@deepseek-ai/dsh-client-connection': ['OperatorPeer'], '@deepseek-ai/dsh-subprocess': ['SubprocessExecutableNotFoundError'], '@deepseek-ai/dsh-scope': ['carrierKeyOf', 'createScope', 'scopeOf', 'scopeTarget'], '@deepseek-ai/dsh-session': ['SESSION_FORMAT_VERSION'], From d07182d3d85165291da0a7ec77810c2eb99d9e7a Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 20 Sep 2026 13:23:28 +0800 Subject: [PATCH 25/60] docs(agents): record the Remote uplink stream as implemented --- .../2026-09-19-remote-duplex-stream.zh.md | 151 ++++++++---------- 1 file changed, 66 insertions(+), 85 deletions(-) rename .agents/notes/{proposed => implemented}/architecture/2026-09-19-remote-duplex-stream.zh.md (78%) diff --git a/.agents/notes/proposed/architecture/2026-09-19-remote-duplex-stream.zh.md b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md similarity index 78% rename from .agents/notes/proposed/architecture/2026-09-19-remote-duplex-stream.zh.md rename to .agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md index d5f1005714..fc5e43db2e 100644 --- a/.agents/notes/proposed/architecture/2026-09-19-remote-duplex-stream.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md @@ -1,6 +1,6 @@ # Agent Note: Remote 双工流:一种流、上行通道与调用上下文 -Status: proposed +Status: implemented [English](2026-09-19-remote-duplex-stream.md) | 中文 @@ -46,7 +46,7 @@ Host 方法也没有"这次调用是谁发起的"这个概念:`InvokeRemoteReq ### 与后台 job 的关系 -后台 job 的进程形双工流面(已存档为草稿 PR,分支 `worktree/job-stdio-stream-pair-archive`)把 job 建成一对镜像的流面,浏览器要接它就需要一条既下行又上行的 Remote 流。它是本 Note 的动机之一,但不是本 Note 的范围:job 要不要一种专用的读写流,是 jobs 那条线自己的架构问题。本 Note 只交付传输层。 +后台 job 的进程形双工流面(已存档为草稿 PR,分支 `worktree/job-stdio-stream-pair-archive`)把 job 建成一对镜像的流面,浏览器要接它就需要一条既下行又上行的 Remote 流。它是本 Note 的动机之一,但不是本 Note 的范围:job 要不要一种专用的读写流,是 jobs 那条线自己的架构问题。本 Note 只覆盖传输层。 ### 目标 @@ -56,7 +56,7 @@ Host 方法也没有"这次调用是谁发起的"这个概念:`InvokeRemoteReq 4. 客户端拿到的不再是裸 `AsyncIterable`,而是一个能读、能写、能关的句柄。 5. 上层协议(snapshot、journal、任何业务自定义的流)不进传输层。 -## 提案 +## 决定 ### 一句话 @@ -88,7 +88,7 @@ Host 方法也没有"这次调用是谁发起的"这个概念:`InvokeRemoteReq | 半关闭 | 一个方向已结束而另一个方向仍开放 | | 项 | 一个方向上的一个业务值;每项独立经 codec 校验 | | inbox | Host 侧每逻辑流一个的有界上行队列 | -| Peer | 连接层被接纳的一方,一个 `PeerScope`;操作者是进程内默认 Peer | +| Peer | 连接层被接纳的一方,一个 `PeerScope`;本 Host 只有操作者一个 Peer | | 调用上下文 | `RemoteInvocation`:本次调用的请求、接收服务、Peer、取消信号与上行入口 | ### 类型签名 @@ -97,7 +97,7 @@ Host 方法也没有"这次调用是谁发起的"这个概念:`InvokeRemoteReq // @deepseek-ai/dsh-typert-protocol /** * 一条 Remote 流。Host 面:方法返回它,运行时就是 AsyncIterable。 - * Client 面:生成方法返回的句柄,见 RemoteStreamHandle。 + * Client 面上生成方法返回的是 RemoteStreamHandle;两个名字各自只有一个含义。 * In 是客户端可上行的项类型;缺省 never 表示该方法不读上行。 */ export type RemoteStream = AsyncIterable @@ -161,7 +161,7 @@ declare module '@deepseek-ai/cordis' { 1. 任何 `mode: 'stream'` 方法都可以调用 `this.ctx.invocation.uplink()`;unary 方法也可以,但它的流在方法返回时结束,上行项只在方法运行期间可读。 2. `uplink()` 未被调用时,上行项在 inbox 里攒着,直到超限(见背压)或流结束被丢弃。这是刻意的:不读上行的方法不用知道上行存在。 3. `In` 缺省为 `never` 时描述符没有 uplink codec;客户端句柄的 `send` 类型为 `never`,编译期就不能发。运行时若仍有帧到达(手工构造的客户端),按 `unknown` 交付。 -4. `this.ctx.invocation` 在非 Remote 调用的 Context 上是 `undefined`;方法内读它时按 TS 的可选处理,或在方法第一行断言。 +4. `this.ctx.invocation` 在非 Remote 调用的 Context 上是 `undefined`;方法内读它时按 TS 的可选处理,或在方法第一行断言。该属性的 accessor 由 `TypertRemoteService` 基类构造时注册一次,任何装有 Remote 服务的组合都能读它。 #### 调用上下文如何到达方法 @@ -185,25 +185,23 @@ export interface PeerScope { } ``` -`PeerRegistry` 放在 `@deepseek-ai/dsh-client-connection`(`src/peer-scope.ts`): +本 Host 只有一个 Peer:操作者。`@deepseek-ai/dsh-client-connection` 在服务 apply 时用 `createScope(connectionCtx, peer)` 建它,与 Agent 建自己的 scope 是同一机制:Peer 对象就是 ScopeKey,`peer.ctx` 承接连接期效果,`dispose()` 随 connection 释放让 fiber 静默。它以 `connection.operator` 暴露。 | 成员 | 语义 | | --- | --- | -| `operator: PeerScope` | 进程内默认 Peer,服务 apply 时打开 | -| `open(): PeerScope` | 为一次连接打开一个 scope,`ctx` 由 `createScope(connectionCtx, peer)` 派生 | -| `bind(carrier, peer)` / `of(carrier)` | 用 WeakMap 把 WebSocket 或 Fetch `Request` 绑到 Peer | -| `dispose()` | scope 回收;`stream-server` 在 `peer.ctx.effect` 里登记 socket 关闭,scope 释放即断连 | +| `connection.operator: PeerScope` | 唯一的 Peer,随 connection 生灭 | +| `connection.admit(request): PeerAdmission` | 过既有的 `requestRejection`:被拒返回 `{ rejection: 401 \| 403 }`,否则返回 `{ peer: operator }`。将来的接纳方在这里接入 | -网关 `handleUpgrade(req, socket, head, peer)` 为该 socket 绑一个固定 Peer 的 opener;`InvokeRemoteRequest.peer?` 与 `RemoteStreamOpener` 的 `peer` 形参缺席时按操作者作答,现有进程内调用方零改动。 +网关 `handleUpgrade(req, socket, head, peer)` 为该 socket 绑一个固定 Peer 的 opener,并在 `peer.ctx.effect` 里登记 socket 关闭;scope 已失效时改为直接以 1001 关闭 socket。`InvokeRemoteRequest.peer?` 与 `RemoteStreamOpener` 的 `peer` 形参缺席时按操作者作答,现有进程内调用方零改动;没有 connection 的组合里网关自建的操作者替身同样经 `createScope` 建立,`dispose()` 契约一致。按 id 查 Peer、把载体关联到第二个 Peer、Peer 的开关事件,都等第一个需要多 Peer 的消费者出现时再加。 -不采用的部分:`@Access`、`AccessDeclaration`、`AccessLevel`、`AccessTarget`、`vouch` / `vouched`、`remote/invoke` 与 `remote/deliver` 事件、`mayDeliver`、`requireVoucher`、`gateway/access-denied`。Peer 是谁、能做什么,由业务插件通过 `peer.ctx` 或按 `PeerScope` 键的注册表自行附加,与本 Note 无关。 +不采用的部分:`@Access`、`AccessDeclaration`、`AccessLevel`、`AccessTarget`、`vouch` / `vouched`、`remote/invoke` 与 `remote/deliver` 事件、`mayDeliver`、`requireVoucher`、`gateway/access-denied`。Peer 是谁、能做什么,由业务插件通过 `peer.ctx` 自行附加,与本 Note 无关。 ### Client 面 ```text -/** Client 面上生成方法返回的句柄;RemoteStream 在 Client 面解析为它。 */ +/** 生成方法在 Client 面返回的句柄。与 Host 的 RemoteStream 是两个名字,从同一个入口导出。 */ export interface RemoteStreamHandle extends AsyncIterable { - /** 发送一个上行项。流已终止或已 end 时抛错。 */ + /** 发送一个上行项。非无损 JSON 值、流已终止或已 end 时抛错。 */ send(item: In): void /** 上行半关闭:发 end 帧。幂等。 */ end(): void @@ -213,9 +211,10 @@ export interface RemoteStreamHandle extends AsyncIterable { ``` - 句柄代表一个代际。载体丢失时 `for await` 以 `RemoteStreamCarrierError` 失败,句柄到此结束;要不要重开、带什么参数重开,是上层协议的事。 -- `send` 是同步的:浏览器 `WebSocket.send` 没有写回调,发送节流没有意义;Host 侧的 inbox 上限是唯一的背压点。 -- 消费者提前 `break` 出 `for await` 等价于 `dispose()`。 -- `$stream` 监督器、`RemoteSnapshotStream`、`RemoteJournalStream` 是上层工具,本 Note 不改它们;它们的 `open` 工厂将来接的是句柄而不是裸 `AsyncIterable`,属于平滑替换。 +- `send` 是同步的:入队前做 `isRemoteJsonValue` 校验,非无损 JSON 值同步抛错。浏览器 `WebSocket.send` 没有写回调,发送节流没有意义;Host 侧的 inbox 上限是唯一的背压点。 +- 收到终止帧(`end` / `error`)时,mux 客户端在收帧路径上立即停泵并关闭上行队列,之后 `send` / `end` 抛错,不等消费者下一次读。 +- `dispose()` 调用载体迭代器的 `return()`,让 `cancel` 帧在无人读下行时也发出;之后句柄的 `next()` 直接结束,缓冲项丢弃。消费者提前 `break` 出 `for await` 等价于 `dispose()`。 +- `$stream` 监督器、`RemoteSnapshotStream`、`RemoteJournalStream` 是上层工具,不在传输层内。它们的 `open` 工厂拿到的对象现在就是句柄,`for await` 行为不变;把 `send` 透出到这些上层协议是后续的事。 ### 线路帧 @@ -233,7 +232,7 @@ export type RemoteStreamServerMessage = | { readonly type: 'end'; readonly streamId: string } ``` -解析规则:`item` 精确键 `type` `streamId` 加可选 `value`,`value` 须为无损 JSON 值(`isRemoteJsonValue`);`end` 精确键 `type` `streamId`。其余形状仍是协议违规,socket 以 1008 关闭。不引入二进制帧。 +解析规则:`item` 精确键 `type` `streamId` 加可选 `value`,`value` 须为无损 JSON 值(`isRemoteJsonValue`);`end` 精确键 `type` `streamId`。其余形状是帧格式违规,socket 以 1008 关闭。不引入二进制帧。 ### 逻辑流状态机 @@ -273,7 +272,7 @@ export type RemoteStreamServerMessage = | 客户端 `dispose` 或 signal 中止 | 任意 | 发 `cancel`;Host `control.abort()`,`cancellableStream` 调方法迭代器的 `return()`,再调 `uplink` 的 `return()`;不发终止帧 | | socket 关闭 | 任意 | Host 中止全部流并等待 `done`;客户端每条流以 `RemoteStreamCarrierError` 失败 | | `end` 之后又收到 `item` | 任意 | 该流以 `gateway/protocol` 错误帧失败并中止;socket 不关闭 | -| 未知 `streamId` 的 `item` / `end` | 任意 | 协议违规,socket 以 1008 关闭,与重复 id 同级 | +| 未知 `streamId` 的 `item` / `end` | 任意 | 忽略,与 `cancel` 相同:Host 结束流并删除 id 后,客户端在途的上行帧属正常现象,不能连累同一 socket 上的其他流 | ### 顺序保证 @@ -281,7 +280,7 @@ export type RemoteStreamServerMessage = ### 背压 -WebSocket 没有 HTTP/2 那样的每流窗口,一个连接一条串行写链,慢流会拖住同连接的其他流。下行今天已经如此,本 Note 不改。上行: +WebSocket 没有 HTTP/2 那样的每流窗口,一个连接一条串行写链,慢流会拖住同连接的其他流。下行的这一性质不变。上行: **有界 inbox 加整流失败。** Host 每条逻辑流的 inbox 按帧的 UTF-8 字节计量,上限为网关配置 `streamInboxBytes`(默认 262144),对所有流生效。超限时整条逻辑流以 `gateway/uplink-overflow` 失败:下行一起断,向客户端发 `error` 帧,`uplink()` 迭代器以同一错误结束。数据不丢弃;一个方法不读上行而客户端持续发,是用法错误,由失败显式暴露。 @@ -301,14 +300,14 @@ credit 帧留待需要持续大流量上行的消费者出现时再加;inbox | 载体 | 改为 | | --- | --- | | `ClientConnectionRpc.open`(`packages/client/connection/src/rpc.ts:245-250`)与 `RpcStreamOpen`(`client/rpc.ts:21-25`) | 追加可选 `uplink?: AsyncIterable`;进程内没有帧也没有 inbox,调用方迭代器直接作为 `uplink()` 的源,背压就是迭代器本身的节拍 | -| worker 隧道(`packages/experimental/webworker-runtime`) | 页面新增 `stream-uplink-item` / `stream-uplink-end` 帧;`serveStream` 把它们组装成 `uplink` 交给 `seams.openStream` | +| worker 隧道(`packages/experimental/webworker-runtime`) | 页面新增 `stream-uplink-item` / `stream-uplink-end` 帧;`serveStream` 把它们组装成 `uplink` 交给 `seams.openStream`。同源可信边界:没有 inbox 上限,`end` 之后的项丢弃 | | `remote-mock`(`packages/test-support/remote-mock`) | `StreamHandle.uplink: AsyncIterable`;`rpc.open` 透传 | 进程内载体的 `peer` 缺席即操作者。 ### 与 `$events` 的关系 -`$events` 是一条保留端点的流;有了上行帧,waterfall 的回答可以成为同一 `streamId` 上的上行项,`clientId` 因 `streamId` 已标识代际而多余,`$events/result` 与 `index.ts:527-529` 的竞态一起消失。不在本 Note 范围,列在迁移的最后。 +`$events` 是一条保留端点的流;有了上行帧,waterfall 的回答可以成为同一 `streamId` 上的上行项,`clientId` 因 `streamId` 已标识代际而多余,`$events/result` 与 `index.ts:527-529` 的竞态一起消失。不在本 Note 范围,见「后续」。 ## 类型与线路定义 @@ -338,12 +337,12 @@ export interface InvocationDescriptor { - `model.ts`:`InvocationModel.uplink?: { boundary: RemoteBoundaryModel }`。 - `analyzer.ts` `remoteResultType`(现 `~1402`):对 `mode: 'stream'`,接受的返回类型包装器为 `Iterable`、`AsyncIterable`、`RemoteStream`。识别 `RemoteStream` 的方式与识别标准库 `AsyncIterable` 相同:符号名加声明所在文件(`@deepseek-ai/dsh-typert-protocol` 的 `types.ts`)。第一个类型参数是下行项,第二个存在且不是 `never` 时生成 `uplink` boundary,键名 `${endpoint}:uplink`。 -- `emitter.ts`:描述符字面量输出 `uplink: { codec }`;生成的 Client 签名返回类型原样渲染为 `RemoteStream`(Client 面解析为句柄类型,见下)。 +- `emitter.ts`:描述符字面量输出 `uplink: { codec }`;生成的 Client 签名返回 `RemoteStreamHandle`。 - 参数循环不再识别任何名为 `uplink` 的参数。 -### Client 面的 `RemoteStream` 解析 +### 两个名字,一个入口 -`RemoteStream` 在 Host 面是 `AsyncIterable`,在 Client 面是 `RemoteStreamHandle`。两个编译面通过 protocol 包按面区分的入口各自导出同名别名;生成代码只写 `RemoteStream`,由所在编译面决定它是什么。这与 protocol 已有的 Host / Client 分面导出一致。 +Host 方法用 `RemoteStream` 声明一条流;Client 持有的是 `RemoteStreamHandle`。两者都从 `@deepseek-ai/dsh-typert-protocol` 主入口导出,句柄接口没有任何 Host 依赖。生成的 Client 契约把返回类型写成 `RemoteStreamHandle`。一个名字只有一个含义:Client 代码从主入口拿到的 `RemoteStream` 永远是声明类型,不会与句柄混淆。 ### 网关 Host(`packages/api/gateway/src`) @@ -395,7 +394,7 @@ const callReceiver = receiverContext.extend({ invocation }).get(descriptor.servi if (descriptor.cancellation !== undefined) args.push(signal) ``` -`GatewayInvocation.uplink()` 第一次调用返回解码器,第二次抛错。`UplinkDecoder` 是手写迭代器:有 codec 时逐项 `decode(codec, value, endpoint, 'uplink')`,失败即 `control.abort(failure)` 并抛出;无 codec 时只做 `isRemoteJsonValue` 校验。`cancellableStream` 的 `finally` 在方法迭代器 `return()` 之后调用解码器的 `return()`。unary 与 stream 的模式分支恢复为今天的两分支。SRC 回退:描述符无 `uplink` codec,`uplink()` 交付 `unknown`。 +`GatewayInvocation.uplink()` 第一次调用返回解码器,第二次抛错。`UplinkDecoder` 是手写迭代器:有 codec 时逐项 `decode(codec, value, endpoint, 'uplink')`,失败即 `control.abort(failure)` 并抛出;无 codec 时只做 `isRemoteJsonValue` 校验。`cancellableStream` 的 `finally` 先关闭解码器(`invocation.close()`)再等待方法迭代器的 `return()`,阻塞在 `uplink.next()` 上的方法得以退出。unary 与 stream 的模式分支恢复为今天的两分支。SRC 回退:描述符无 `uplink` codec,`uplink()` 交付 `unknown`。 ### 网关 Host mux(`stream-server.ts`) @@ -416,8 +415,8 @@ class UplinkInbox implements AsyncIterable, AsyncIterator { ``` open : 已存在 → 抛错(socket 1008);否则创建 { control, inbox, done },启动 pump -item : 不存在 → 抛错(socket 1008);否则 inbox.push(value, bytes) -end : 不存在 → 抛错(socket 1008);否则 inbox.end() +item : 不存在 → 忽略;否则 inbox.push(value, bytes) +end : 不存在 → 忽略;否则 inbox.end() cancel : 不存在 → 忽略;否则 control.abort(new Error('Remote stream cancelled')) ``` @@ -430,28 +429,18 @@ cancel : 不存在 → 忽略;否则 control.abort(new Error('Remote stream ca - `RemoteStreamMuxClient.open(endpoint, payload, signal, uplink?)`:在发出 `open` 帧后启动上行泵,逐项发 `item`,源结束发 `end`;下行终止或 signal 中止时停泵并调用源的 `return()`(不等待,避免键盘类生成器卡死);源抛错时 `inbox.fail(error)`,下行以该错误失败,`finally` 按既有规则发 `cancel`。泵检查 `this.socket === socket`,旧代际不往新 socket 上发帧。 - 进程内载体:`connection.rpc.open(channel, endpoint, payload, signal, uplink)`。 -## 实现要点 +## 落点 -按文件列出。实现者按此顺序推进,每步都应能单独 `typecheck`。 - -| 步 | 文件 | 改动 | -| --- | --- | --- | -| 1 | `packages/typert/protocol/src/types.ts` | `RemoteStream` 别名、`PeerId`、`PeerScope`、`RemoteInvocation`、`ctx.invocation` 声明合并、`InvocationDescriptor.uplink`;`mode` 仍只有 `'stream'` | -| 2 | `packages/typert/protocol/src/index.ts` | 修饰器恢复只认 `mode: 'stream'`;导出新类型;Client 面入口导出 `RemoteStreamHandle` 并把 `RemoteStream` 解析为它 | -| 3 | `packages/typert/registry/src/service.ts` | 加载校验:`uplink.codec` 为有效的严格 codec | -| 4 | `packages/typert/generator/src/model.ts` | `InvocationModel.uplink?: { boundary }` | -| 5 | `packages/typert/generator/src/analyzer.ts` | `remoteResultType` 识别 `RemoteStream` 并生成 `uplink` boundary;参数循环只认业务参数与末位 `signal` | -| 6 | `packages/typert/generator/src/emitter.ts` | 输出 `uplink: { codec }`;生成签名的参数恢复;返回类型渲染 `RemoteStream` | -| 7 | `packages/client/connection/src/peer-scope.ts`(新,从 `remote-client-access` 分支照搬) | `PeerRegistry`:`operator`、`open`、`bind` / `of`、`dispose`;`rpc.ts` 暴露 `peers`;`/api` 与升级路由绑定 Peer | -| 8 | `packages/api/gateway/src/stream-protocol.ts` | `item` / `end` 帧(已有) | -| 9 | `packages/api/gateway/src/stream-server.ts` | opener 加 `peer`;`handleUpgrade(req, socket, head, peer)`;`UplinkInbox`(已有,改名配置) | -| 10 | `packages/api/gateway/src/types.ts` | `InvokeRemoteRequest.uplink` / `peer`;`wireStream.open` 签名 | -| 11 | `packages/api/gateway/src/index.ts` | `Config.streamInboxBytes`;`GatewayInvocation`;`extend({ invocation })`;`UplinkDecoder` 按描述符 codec 可选;`operatorPeer()`;unary 与 stream 两个模式分支 | -| 12 | `packages/api/gateway/src/client/index.ts`、`stream-client.ts` | `invokeStream` 返回句柄;泵(已有)改由句柄的队列驱动 | -| 13 | `packages/client/connection/src/rpc.ts`、`client/rpc.ts` | `open` 加可选 `uplink`(已有) | -| 14 | `packages/experimental/webworker-runtime` | 两种上行帧与 `serveStream` 组装(已有) | -| 15 | `packages/test-support/remote-mock` | `StreamHandle.uplink`(已有) | -| 16 | 文档 | `docs/api-gateway.md:5, 160`;`packages/api/gateway/README.md:12, 35, 46, 54`、错误码与 Known Limitations;`packages/api/README.md:12, 30, 38`;`packages/client/connection/README.md:30`;`docs/subsystems/typert.md:88-89, 113`;`protocol/src/index.ts:96, 103` 与 `types.ts:269, 294` 的 JSDoc;Cordis 目录再生成 | +| 包 | 承载 | +| --- | --- | +| `dsh-typert-protocol`(`types.ts`、`index.ts`) | `RemoteStream`、`RemoteStreamHandle`、`PeerId`、`PeerScope`、`RemoteInvocation`、`ctx.invocation` 声明合并、`InvocationDescriptor.uplink`;修饰器只认 `mode: 'stream'`;`TypertRemoteService` 构造时注册 `invocation` accessor | +| `dsh-typert-registry` | 加载校验 `uplink.codec` 为有效的严格 codec | +| `dsh-typert-generator`(`model.ts`、`analyzer.ts`、`emitter.ts`) | `remoteResultType` 识别 `RemoteStream` 并生成 `uplink` boundary;描述符输出 `uplink: { codec }`;生成的 Client 签名返回 `RemoteStreamHandle`;参数循环只认业务参数与末位 `signal` | +| `dsh-client-connection`(`operator-peer.ts`、`rpc.ts`、`rpc-host.ts`、`index.ts`) | 操作者 `PeerScope`;`connection.operator` 与 `admit`;`/api` 与升级路由经 `admit` 取 Peer;`rpc.open` 的可选 `uplink` | +| `dsh-api-gateway` Host(`stream-protocol.ts`、`stream-server.ts`、`types.ts`、`index.ts`) | `item` / `end` 帧;`UplinkInbox`;`Config.streamInboxBytes`;`GatewayInvocation` 与 `extend({ invocation })`;`UplinkDecoder`;`handleUpgrade(req, socket, head, peer)`;`operatorPeer()` | +| `dsh-api-gateway` Client(`client/index.ts`、`client/stream-client.ts`) | `invokeStream` 返回句柄;上行泵由句柄的队列驱动 | +| `dsh-webworker-runtime`、`dsh-remote-mock` | worker 隧道的两种上行帧与 `serveStream` 组装;`StreamHandle.uplink`;直接代理返回真句柄 | +| 文档 | gateway、api 组、connection、protocol、generator 的 README;`docs/api-gateway`;`docs/subsystems/typert` 的 type-equiv 块;config 与 Cordis 目录 | ## 关键流程时序 @@ -498,8 +487,8 @@ Client 调用方 mux client mux server | 事件 | opening | running | 上行已 end | 下行已终止 | | --- | --- | --- | --- | --- | -| 收到 `item` | 入 inbox 缓冲 | 入 inbox | 流以 `gateway/protocol` 失败 | 流已不在表中 → socket 1008 | -| 收到 `end` | inbox.end | inbox.end | 幂等 | 同上 | +| 收到 `item` | 入 inbox 缓冲 | 入 inbox | 流以 `gateway/protocol` 失败 | 忽略 | +| 收到 `end` | inbox.end | inbox.end | 幂等 | 忽略 | | 收到 `cancel` | abort | abort | abort | 忽略 | | inbox 超限 | 流以 `uplink-overflow` 失败 | 同 | 不可能 | 不可能 | | 解码失败 | 不可能 | 流以 `input-invalid` 失败 | 同 | 不可能 | @@ -523,7 +512,7 @@ Client 调用方 mux client mux server ## 最小 echo 方法 -验收用,放在网关测试支持里: +`packages/api/gateway/tests/echo-stream.host.spec.ts` 让同一个方法分别穿过 WebSocket 载体与进程内载体: ```text class EchoService extends TypertRemoteService { @@ -545,17 +534,6 @@ const replies: string[] = [] for await (const reply of stream) replies.push(reply) // ['> a', '> b'] ``` -## 迁移步骤 - -| 阶段 | 内容 | 范围 | -| --- | --- | --- | -| A | 实现要点表第 1 到 15 步 | 本 Note | -| B | 第 16 步文档;typert 与网关的测试;echo 端到端 | 合并前 | -| C | `$stream` / snapshot / journal 的 `open` 工厂接句柄 | 后续 | -| D | Web 终端:一条 `attach` 流取代 `follow` 加 `write` 加 `resize` | 独立 PR | -| E | 双工 `$events`:回答成为上行项 | 独立 PR | -| F | credit 帧 | 按需 | - ## 考虑过的替代方案 **独立的 `mode: 'duplex'` 加一个与 `signal` 同级的保留参数 `uplink: AsyncIterable`。** 每一处都是特殊逻辑:analyzer 要认第二个保留参数及其位置规则,生成签名多一个参数,加载期要校验 `duplex ⟺ uplink`,mux 不知道方法模式所以网关要替 `'stream'` 方法拒绝上行帧。双工是传输层流的固有性质,不是方法的一种模式:载体本来就有 `streamId`、`open`、`cancel`,上行只是再加两种帧,任何流都能收,不用就不发。 @@ -580,29 +558,32 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b'] **WebTransport 或 HTTP/2 双向流。** 有每流流控,但浏览器与 Node 两侧的支持、代理穿透与 Electron 载体都不成熟;在现有 mux 上加两种帧比换传输层便宜几个量级。 -## 验收条件 +## 后果 -1. `RemoteStream` 在 Host 面是 `AsyncIterable`,在 Client 面是 `RemoteStreamHandle`;一个方法从 `AsyncIterable` 改为 `RemoteStream` 不改变任何生成产物。 -2. analyzer 对 `RemoteStream` 生成 `uplink` codec;`In` 缺省或为 `never` 时描述符无 `uplink`。 -3. 网关 Host 对每次调用派生 `extend({ invocation })` 视图;方法体内 `this.ctx.invocation.peer` 是接纳该 socket 的 Peer,进程内调用是操作者。 -4. `uplink()` 只能取一次;有 codec 时逐项解码并在失败时以 `gateway/input-invalid` 中止流;无 codec 时交付 `unknown`。 -5. mux 接受 `item` / `end`,opening 期间缓冲,`end` 后的 `item` 使流以 `gateway/protocol` 失败而不关 socket,未知 `streamId` 关 socket,超限以 `gateway/uplink-overflow` 失败并发 `error` 帧。 -6. 客户端句柄:`send` 在 open 前入队、open 后发帧,终止后抛错;`end` 幂等;`dispose` 发 `cancel`;下行先终止时停泵并调用源的 `return()`。 -7. 进程内载体、worker 隧道、remote-mock 都能打开带上行的流并把项交付到 `uplink()`。 -8. echo 方法在 WebSocket 与进程内载体上行为一致。 -9. 既有 `'stream'` 与 unary 方法的行为与线路帧不变;既有网关测试不改期望值即通过。 -10. `pnpm run typecheck`、`test:coverage`、`lint`、`duplication`、`build`、`hygiene`、`doc-sync` 通过。 +- **买到的**:一条流两个方向,终端按键、审批回答、后台任务 stdin 这类交互式场景不再各造一套"一条流加一个 unary";Host 方法知道调用者;上行类型与下行类型在返回类型里一处声明,上行项与下行项同样逐项严格校验;既有 `AsyncIterable` 方法与 unary 方法的生成物与线路帧完全不变。 +- **`this.ctx.invocation` 在非 Remote 调用下是 `undefined`**,方法读它要处理可选;一个只被进程内直接调用的服务方法读到 `undefined` 是正确行为。 +- **inbox 上限对所有流生效**:一个不读上行的方法收到大量上行帧会整流失败。这是刻意的显式失败,README 记明。 +- **Peer 只有操作者一个**,`admit` 是唯一的接纳点;第一个需要第二个 Peer 的消费者要在 `dsh-client-connection` 加上打开、关联与事件。 +- **上行与下行之间没有跨方向顺序保证**;需要请求应答配对的协议自带序号。 +- **上层协议尚未透出 `send`**:`$stream`、snapshot、journal 的消费者今天只用下行;它们拿到的对象已经是句柄。 +- **worker 隧道与 WebSocket 载体行为不同**:没有 inbox 上限,`end` 之后的项丢弃,因为页面到 worker 是同源可信边界。 -## 风险 +## 测试 -- **`RemoteStream` 分面解析**依赖 protocol 包按 Host / Client 面导出同名别名;若某个编译面同时引用两侧类型,会看到两个不同的 `RemoteStream`。分面导出是仓库既有做法,但这是第一个在两面语义不同的类型。 -- **`this.ctx.invocation` 在非 Remote 调用下是 `undefined`**,方法体读它要处理可选;一个只被进程内直接调用(不经网关)的服务方法读到 `undefined` 是正确行为。 -- **PeerRegistry 从另一分支照搬**,其升级路由与 `/api` 路由的绑定改动落在 `dsh-client-connection`,是本 Note 唯一触及连接层的地方;照搬时剔除 access 相关成员。 -- **inbox 上限对所有流生效**,一个不读上行的方法收到大量上行帧会整流失败;这是刻意的显式失败,写进 README。 -- **本 Note 只有中文版**,配对门禁在补齐英文版之前失败;合并前补。 +- `packages/api/gateway/tests/echo-stream.host.spec.ts`:同一个 echo 方法分别穿过 WebSocket 载体与进程内载体。 +- 网关 Host:帧解析的精确键校验;`UplinkInbox` 的 opening 缓冲、`end` 后 `item`、超限、未知 `streamId` 忽略、socket 关闭时的收尾;`UplinkDecoder` 有无 codec 两种交付与解码失败中止;`uplink()` 只能取一次;`extend({ invocation })` 视图与 `peer` 的来源;`cancellableStream` 先关 uplink 再 `return()`。 +- 网关 Client:句柄的 `send`(JSON 校验、open 前入队、终止后抛错)、`end` 幂等、`dispose` 驱动 `cancel` 帧并结束后续读取;泵的三种退出;终止帧到达即停泵。 +- typert:analyzer 对 `RemoteStream`、`RemoteStream`、`never` 与既有 `AsyncIterable` 的处理;修饰器选项;registry 加载校验。 +- connection:`admit` 的 401、403 与操作者三种结果;操作者经桥与共享处理器到达;随 connection 释放。 +- remote-mock、worker 隧道:上行透传;直接代理返回真句柄。 +- 既有 stream 与 unary 方法的测试不改期望值即通过。 -## 未决问题 +## 后续 -1. `streamInboxBytes` 的默认值:262144 对键盘与 stdin 远超需要;若第一个消费者是终端粘贴,可能与 `terminal-controller` 的 `maxInputBytes` 对齐。 -2. 是否需要 Host 发起的半关闭帧(Host 不再读上行但继续写下行)。没有消费者,不加。 -3. unary 方法是否应禁止 `uplink()`:它能读到方法运行期间到达的项,语义成立但用处存疑;先允许,README 注明。 +- `$stream` / snapshot / journal 把句柄的 `send` 透出给上层协议。 +- Web 终端:一条 `attach` 流取代 `follow` 加 `write` 加 `resize`,`attachmentId` 退场。 +- 双工 `$events`:waterfall 的回答成为同一 `streamId` 上的上行项,`$events/result` 与 `clientId` 退场。 +- credit 帧:等需要持续大流量上行的消费者出现;inbox 上限就是初始授信。 +- `streamInboxBytes` 默认值 262144 对键盘与 stdin 远超需要;若第一个消费者是终端粘贴,可能与 `terminal-controller` 的 `maxInputBytes` 对齐。 +- Host 发起的半关闭帧(Host 不再读上行但继续写下行):没有消费者,不加。 +- unary 方法的 `uplink()`:能读到方法运行期间到达的项,语义成立但用处存疑;允许,README 注明。 From 5fbae69d18148b7c0c5fcb6d45d0907583755b45 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 20 Sep 2026 13:30:37 +0800 Subject: [PATCH 26/60] docs(agents): pair the Remote uplink stream note in English --- .../2026-09-19-remote-duplex-stream.i18n.yaml | 6 + .../2026-09-19-remote-duplex-stream.md | 595 ++++++++++++++++++ .../2026-09-19-remote-duplex-stream.zh.md | 10 +- 3 files changed, 609 insertions(+), 2 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md diff --git a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml new file mode 100644 index 0000000000..6981faa8db --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.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 .agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md +2026-09-19-remote-duplex-stream.md: bbfcc7763333b75f41df37bb187da38c9c4b261e +2026-09-19-remote-duplex-stream.zh.md: 5fee4d934fdba46db2e455e56fd4aaf20d50d575 diff --git a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md new file mode 100644 index 0000000000..bbfcc77633 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md @@ -0,0 +1,595 @@ +# Agent Note: Remote duplex stream: one kind of stream, an uplink channel, and an invocation context + +Status: implemented + +English | [中文](2026-09-19-remote-duplex-stream.zh.md) + +## Problem + +### Current state + +`dsh-api-gateway` multiplexes every Typert Remote stream over one WebSocket at the fixed path `/api/remote.mux` (`packages/api/gateway/src/stream-protocol.ts:6`). A Host method decorated with `@Remote({ mode: 'stream' })` that returns an `Iterable` or `AsyncIterable` becomes one Host-to-Client logical stream; the optional final `signal: AbortSignal` is the only reserved parameter, never enters the wire arguments, and is appended by the Gateway after the decoded business parameters (`packages/api/gateway/src/index.ts:613`). The generated method the Client receives returns a bare `AsyncIterable`. + +There are only five wire frames: + +| Direction | Frame | Definition | +| --- | --- | --- | +| Client to Host | `{ type: 'open', streamId, endpoint, payload }` | `stream-protocol.ts:243-249` | +| Client to Host | `{ type: 'cancel', streamId }` | `stream-protocol.ts:250` | +| Host to Client | `{ type: 'item', streamId, value? }` | `stream-protocol.ts:261` | +| Host to Client | `{ type: 'error', streamId, error: { code, message, details } }` | `stream-protocol.ts:262` | +| Host to Client | `{ type: 'end', streamId }` | `stream-protocol.ts:263` | + +The Client can send only open and cancel. The parser `parseRemoteStreamClientMessage` (`stream-protocol.ts:270-284`) throws on any other frame, and a Host connection that receives one closes the whole socket with 1008 (`stream-server.ts:121-125`). In short, the Gateway today supports only "a backend iterable sent to the frontend". Every feature that needs the frontend to send data to the backend continuously takes its own detour. + +A Host method also has no notion of who initiated this call: `InvokeRemoteRequest = { namespace, method, args, signal }` (`packages/api/gateway/src/types.ts:10-19`) has no caller slot, `RemoteStreamOpener` has the signature `(endpoint, payload, signal)` (`stream-server.ts:13-17`), and the WebSocket checks the cookie once, at the handshake. + +### Three unrelated uplink mechanisms + +| Feature | Uplink path | Location | Cost | +| --- | --- | --- | --- | +| Web terminal keystrokes | One unary RPC `terminal.write(agent, id, attachmentId, data)` per xterm `onData`; the Client serializes them through a promise chain and caps its own byte budget | `packages/api/terminal-controller/src/client/model.ts:193-205`; Host `src/index.ts:229-233` | One HTTP round trip and one authentication per keystroke; two carriers correlated by hand through `attachmentId`; `inputFull` when the budget is exhausted | +| File upload | A `Blob` or `ReadableStream` goes to the native `POST /api/session/uploadFileBinary` outside the Remote layer, with `duplex: 'half'` | `packages/client/file-upload/src/client/runtime.ts:198-211`; route `src/index.ts:72-80` | Bypasses Typert descriptors; no type projection | +| Answers to approvals and questions | The Host sends waterfall frames down the `$events` stream, the Client answers through a separate unary RPC `$events/result`, correlated by `clientId` plus `eventId` | `packages/api/gateway/src/client/remote-events.ts:219-224`; Host `index.ts:357-369` | A second correlation key; `index.ts:527-529` records the race this creates | + +These three mechanisms solve the same problem: the Client wants to write into a logical stream that is already open. + +### Concrete costs + +- **Aligning two carriers.** The terminal uses `attachmentId` to correlate the `follow` stream with the `write` RPC and to decide who owns input. Approvals use `clientId` to correlate the `$events` stream with the `$events/result` RPC. +- **No ordering guarantee.** A unary RPC travels over HTTP and is unordered relative to the WebSocket downlink; the terminal can preserve order only by serializing writes on the Client. +- **Separate authentication and lookup per uplink.** Every `write(agent, id, attachmentId, data)` resolves `agent`, looks up the terminal, and validates the attachment. +- **No EOF.** The Client cannot express "I am done writing"; a half-close needs yet another RPC. +- **Cancellation does not cover both halves.** Cancelling the downlink stream does not cancel queued uplink writes, and vice versa. +- **Backpressure implemented three times.** The downlink pulls the iterator at the pace of the socket write callback (`stream-server.ts:155-199`); the terminal uses a bounded follower that makes a slow consumer fail explicitly (`terminal-controller/src/stream.ts:21-32`); the uplink relies on the Client capping `maxInputBytes` itself. +- **The documentation is already stale.** `docs/api-gateway.md:160` still says Remote handles one request and one result, a sentence that lags the stream mode the README records. + +### Relation to background jobs + +The process-shaped duplex stream face of background jobs (archived as a draft PR on branch `worktree/job-stdio-stream-pair-archive`) models a job as a pair of mirrored stream faces; for the browser to attach to it, one Remote stream must carry both a downlink and an uplink. It is one motivation for this Note but not in its scope: whether jobs need a dedicated read-write stream is an architecture question for the jobs line itself. This Note covers only the transport layer. + +### Goals + +1. The transport layer has one kind of stream, duplex by nature: downlink `item` / `end` / `error`, uplink `item` / `end`, one `streamId`, one generation. +2. The uplink type and the downlink type are written in the same place: the method's return type. +3. A Host method takes its uplink from the invocation context; the invocation context also carries the caller (the Peer). +4. The Client receives a handle that can read, write, and close, not a bare `AsyncIterable`. +5. Upper-layer protocols (snapshot, journal, any business-defined stream) stay out of the transport layer. + +## Decision + +### In one sentence + +Every Remote stream accepts a Client uplink. The method's return type `RemoteStream` declares the downlink item type and the uplink item type together; the generator derives two codecs from it. A Host method takes the uplink iterator through `this.ctx.invocation.uplink()` and learns the caller through `this.ctx.invocation.peer`. The generated Client method returns a `RemoteStream` handle: `for await` reads the downlink, `send` / `end` write the uplink, `dispose` closes. Reopening after disconnection, cursor resumption, and baseline validation all belong to upper-layer protocols. + +``` + Client Host + ───────────────────────────────── ─────────────────────────────────────────── + const s = remote.job.attach(req, signal) + │ open { streamId, endpoint, payload } ───────▶ opener(endpoint, payload, uplink, peer, control) + │ │ prepareInvocation + │ s.send(v1) → item { streamId, v1 } ───────▶ inbox.push(v1) │ invocation = { request, service, peer, signal, uplink() } + │ s.send(v2) → item { streamId, v2 } ───────▶ inbox.push(v2) │ receiverContext.extend({ invocation }).get(service) + │ s.end() → end { streamId } ───────▶ inbox.end() │ method(...args, signal) + │ ◀─────── item { streamId, o1 } │ for await (v of this.ctx.invocation.uplink()) … + │ for await (o of s) … ◀─────── item { streamId, o2 } │ yield o … + │ s.dispose() → cancel { streamId } ───────▶ control.abort() │ signal 中止;uplink.return() + │ ◀─────── end | error +``` + +### Terms + +| Term | Meaning | +| --- | --- | +| Logical stream | One call identified by one `streamId`, spanning `open` to the terminal frame | +| Generation | One physical lifetime of a logical stream. The transport layer knows exactly one generation; disconnection is failure, and reopening belongs to the upper layer | +| Downlink | The Host-to-Client `item` sequence, terminated by `end` or `error` | +| Uplink | The Client-to-Host `item` sequence, terminated by `end` (half-close) or ended together with `cancel` | +| Half-close | One direction has ended while the other remains open | +| Item | One business value in one direction; every item is validated by the codec independently | +| inbox | The bounded uplink queue the Host keeps per logical stream | +| Peer | The party the connection layer admitted, one `PeerScope`; this Host has exactly one Peer, the operator | +| Invocation context | `RemoteInvocation`: the request, receiving service, Peer, cancellation signal, and uplink entry of this call | + +### Type signatures + +```text +// @deepseek-ai/dsh-typert-protocol +/** + * 一条 Remote 流。Host 面:方法返回它,运行时就是 AsyncIterable。 + * Client 面上生成方法返回的是 RemoteStreamHandle;两个名字各自只有一个含义。 + * In 是客户端可上行的项类型;缺省 never 表示该方法不读上行。 + */ +export type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } + +// Host +@Remote({ mode: 'stream' }) +async *attach(request: JobAttachRequest, signal: AbortSignal): RemoteStream { + const uplink = this.ctx.invocation.uplink() + void (async () => { for await (const frame of uplink) … })() + yield … +} + +// 生成的 Client 签名:参数不变,返回类型不变 +attach(request: JobAttachRequest, signal?: AbortSignal): RemoteStream + +// Client 使用 +const stream = remote.job.attach(req, signal) +for await (const frame of stream) … +stream.send(frame) // 类型为 JobInputFrame +stream.end() +stream.dispose() +``` + +`RemoteStream` is an alias rather than an interface, deliberately: if the transport layer later takes a Node stream form, only the alias definition and the adapters at both ends change; method signatures, descriptors, and upper-layer protocols stay untouched. + +### Host face + +```text +/** 本次 Remote 调用的上下文。方法通过 this.ctx.invocation 读它。 */ +export interface RemoteInvocation { + readonly request: { + readonly namespace: string + readonly method: string + readonly args: Readonly> + } + /** 接收服务的 Cordis service key。 */ + readonly service: string + /** 发起调用的 Peer。进程内载体与未接纳的调用是操作者。 */ + readonly peer: PeerScope + /** 载体取消:客户端 cancel、socket 关闭、上行失败。 */ + readonly signal: AbortSignal + /** + * 本次调用的上行项。只能取一次,第二次抛错。描述符带 uplink codec 时 + * 逐项解码为 In;不带时交付 unknown,只做 JSON 安全校验。 + * 客户端 end 后迭代结束;方法结束下行时网关调用它的 return(),未消费的项丢弃。 + * 泛型 In 只是调用方的类型断言,运行时按描述符解码,不做交叉校验。 + */ + uplink(): AsyncIterable +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** 本 Context 为之派生的 Remote 调用;非 Remote 调用派生的 Context 上为 undefined。 */ + readonly invocation: RemoteInvocation | undefined + } +} +``` + +Rules: + +1. Any `mode: 'stream'` method may call `this.ctx.invocation.uplink()`; a unary method may too, but its stream ends when the method returns, so uplink items are readable only while the method runs. +2. While `uplink()` has not been called, uplink items accumulate in the inbox until the limit is exceeded (see Backpressure) or the stream ends and they are dropped. This is deliberate: a method that does not read the uplink need not know the uplink exists. +3. When `In` defaults to `never`, the descriptor has no uplink codec; the Client handle's `send` has type `never`, so nothing can be sent at compile time. If frames still arrive at runtime (from a hand-built client), they are delivered as `unknown`. +4. `this.ctx.invocation` is `undefined` on a Context not derived from a Remote call; a method that reads it handles the optional as TypeScript requires, or asserts it on the method's first line. The accessor for that property is registered once when the `TypertRemoteService` base class is constructed, so any composition that installs a Remote service can read it. + +#### How the invocation context reaches the method + +The mechanism is carried over from the `remote-client-access` branch, and the Cordis source confirms that it holds: + +- `ctx.extend(meta)` is `Object.create(parent)` plus own properties (`vendor/cordis/src/context.ts:99-107`): no registration, no fiber, nothing to release, so deriving one per call costs nothing. +- `ctx.get(service)` returns the `createTraceable(ctx, service)` proxy (`vendor/cordis/src/reflect.ts:233-234`, `utils.ts:117-125`). Reading the `ctx` property returns the accessing Context directly (`utils.ts:175`); reading a method wraps it in `createShadowMethod`, whose `apply` replaces `this` with a shadow receiver whose `ctx` is `ctx.extend({ [symbols.shadow]: origin })` (`utils.ts:149-163`). +- The Gateway therefore writes `receiverContext.extend({ invocation }).get(descriptor.service)` to obtain a per-call view and calls `Reflect.apply(method, callReceiver, args)`; inside the method body, `this.ctx.invocation` reads this call through the prototype chain. The deferred execution of an async generator body is unaffected, because `this` was bound to the shadow object at call time. + +### PeerScope + +```text +/** 一个 Peer 的不透明身份。 */ +export type PeerId = Branded<'PeerId'> + +/** 一个 Peer 在本 Host 上的会话:接纳它的一方打开与释放;ctx 拥有连接期注册。 */ +export interface PeerScope { + readonly id: PeerId + readonly ctx: Context + dispose(): Promise +} +``` + +This Host has exactly one Peer: the operator. `@deepseek-ai/dsh-client-connection` creates it with `createScope(connectionCtx, peer)` when the service applies, the same mechanism an Agent uses to create its own scope: the Peer object is the ScopeKey, `peer.ctx` carries connection-lifetime effects, and `dispose()` runs with the Connection's release and quiets the fiber. It is exposed as `connection.operator`. + +| Member | Semantics | +| --- | --- | +| `connection.operator: PeerScope` | The only Peer; lives and dies with the Connection | +| `connection.admit(request): PeerAdmission` | Runs the existing `requestRejection`: returns `{ rejection: 401 \| 403 }` when refused, otherwise `{ peer: operator }`. Future admitters plug in here | + +The Gateway's `handleUpgrade(req, socket, head, peer)` binds an opener with a fixed Peer to that socket and registers socket closure in `peer.ctx.effect`; when the scope is already inactive, it closes the socket directly with 1001 instead. When `InvokeRemoteRequest.peer?` or the `peer` parameter of `RemoteStreamOpener` is absent, the answer is the operator, so existing in-process callers change nothing; in a composition without a Connection, the operator stand-in the Gateway builds for itself is likewise created through `createScope`, with the same `dispose()` contract. Looking up a Peer by id, associating a carrier with a second Peer, and Peer open and close events all wait until the first consumer that needs multiple Peers appears. + +Not adopted: `@Access`, `AccessDeclaration`, `AccessLevel`, `AccessTarget`, `vouch` / `vouched`, the `remote/invoke` and `remote/deliver` events, `mayDeliver`, `requireVoucher`, `gateway/access-denied`. Who the Peer is and what it may do are attached by business plugins on their own through `peer.ctx` and are outside this Note. + +### Client face + +```text +/** 生成方法在 Client 面返回的句柄。与 Host 的 RemoteStream 是两个名字,从同一个入口导出。 */ +export interface RemoteStreamHandle extends AsyncIterable { + /** 发送一个上行项。非无损 JSON 值、流已终止或已 end 时抛错。 */ + send(item: In): void + /** 上行半关闭:发 end 帧。幂等。 */ + end(): void + /** 取消整条逻辑流:发 cancel 帧(未收到终止帧时),下行迭代器安静结束。 */ + dispose(): void +} +``` + +- The handle represents one generation. When the carrier is lost, `for await` fails with `RemoteStreamCarrierError` and the handle ends there; whether to reopen, and with which arguments, belongs to the upper-layer protocol. +- `send` is synchronous: it runs the `isRemoteJsonValue` check before enqueuing and throws synchronously on a value that is not lossless JSON. The browser's `WebSocket.send` has no write callback, so throttling sends would be meaningless; the Host-side inbox limit is the only backpressure point. +- When a terminal frame (`end` / `error`) arrives, the mux client stops the pump and closes the uplink queue immediately on the receive path, so later `send` / `end` throw without waiting for the consumer's next read. +- `dispose()` calls `return()` on the carrier iterator, so the `cancel` frame is sent even when nobody is reading the downlink; afterwards the handle's `next()` ends immediately and buffered items are dropped. A consumer that `break`s out of `for await` early is equivalent to `dispose()`. +- The `$stream` supervisor, `RemoteSnapshotStream`, and `RemoteJournalStream` are upper-layer tools outside the transport layer. The object their `open` factories receive is the handle itself, with unchanged `for await` behavior; exposing `send` to these upper-layer protocols is deferred. + +### Wire frames + +```text +export type RemoteStreamClientMessage = + | { readonly type: 'open'; readonly streamId: string; readonly endpoint: string; readonly payload: unknown } + | { readonly type: 'item'; readonly streamId: string; readonly value?: unknown } // 新增:上行项 + | { readonly type: 'end'; readonly streamId: string } // 新增:上行半关闭 + | { readonly type: 'cancel'; readonly streamId: string } + +/** Host 发出的帧不变。 */ +export type RemoteStreamServerMessage = + | { readonly type: 'item'; readonly streamId: string; readonly value?: unknown } + | { readonly type: 'error'; readonly streamId: string; readonly error: RemoteStreamFailure } + | { readonly type: 'end'; readonly streamId: string } +``` + +Parsing rules: `item` has exactly the keys `type` and `streamId` plus an optional `value`, and `value` must be a lossless JSON value (`isRemoteJsonValue`); `end` has exactly the keys `type` and `streamId`. Any other frame is a frame-format violation, and the socket closes with 1008. No binary frames are introduced. + +### Logical stream state machine + +#### Host side + +``` + open 帧 + (无) ─────────────────────▶ opening ──── opener 解析完成 ────▶ running + │ │ + │ item 帧: inbox.push │ item 帧: inbox.push + │ end 帧: inbox.end │ end 帧: inbox.end(上行半关闭) + │ │ 方法 yield: 发 item 帧 + │ cancel 帧 / socket 关闭 ────────▶│ cancel 帧 / socket 关闭: control.abort + ▼ ▼ + aborted ◀───────────────────────── finished(发 end 或 error 帧) +``` + +`item` frames that arrive during `opening` enter the inbox: `receive()` is synchronous and creates the `ActiveStream` and its inbox while handling the `open` frame, whereas the opener is asynchronous. Items the Client sends immediately after `open` are not lost. + +#### Client side + +``` + 调用生成方法 ──▶ 等 socket ──▶ 发 open ──▶ 句柄可用 + │ send(v): 发 item;end(): 发 end + │ 下行:inbox.next() → yield;end → 结束;error → 抛 + │ 下行先终止:send/end 抛错 + │ dispose 或调用方 signal 中止:发 cancel(未收到终止帧时) +``` + +### Half-close and termination + +| Uplink | Downlink | Result | +| --- | --- | --- | +| Client `end` | Still open | The Host's `uplink()` iteration ends; the method keeps producing. This is the stdin EOF form | +| Still open | The Host method finishes and sends `end` | The Client handle terminates, and later `send` / `end` throw; the Host calls `return()` on the `uplink` iterator and drops unconsumed items | +| Still open | The Host method throws and sends `error` | Same as above; the Client downlink fails with `RemoteError` | +| Client `dispose` or signal abort | Any | Sends `cancel`; the Host runs `control.abort()`, `cancellableStream` calls `return()` on the method iterator and then on `uplink`; no terminal frame is sent | +| Socket closes | Any | The Host aborts every stream and awaits `done`; on the Client every stream fails with `RemoteStreamCarrierError` | +| `item` received after `end` | Any | The stream fails with a `gateway/protocol` error frame and aborts; the socket stays open | +| `item` / `end` with an unknown `streamId` | Any | Ignored, like `cancel`: after the Host ends a stream and deletes its id, uplink frames still in flight from the Client are normal and must not take down the other streams on the same socket | + +### Ordering guarantees + +Frames on one socket arrive in total order: a logical stream's `open`, `item`…, `end` reach the Host in send order, and uplink items enter `uplink()` in the order of `send`. There is no cross-direction ordering guarantee between uplink and downlink; a protocol that needs request-response pairing carries its own sequence numbers. + +### Backpressure + +WebSocket has no per-stream window like HTTP/2: one connection is one serial write chain, and a slow stream holds up the other streams on the same connection. This property of the downlink is unchanged. For the uplink: + +**A bounded inbox plus whole-stream failure.** The Host meters each logical stream's inbox in UTF-8 bytes of its frames, with the limit set by the Gateway config `streamInboxBytes` (default 262144) and applied to every stream. On overflow the whole logical stream fails with `gateway/uplink-overflow`: the downlink breaks with it, an `error` frame goes to the Client, and the `uplink()` iterator ends with the same error. No data is discarded; a method that does not read its uplink while the Client keeps sending is a usage error, and the failure exposes it explicitly. + +Credit frames wait until a consumer that needs sustained high-volume uplink appears; the inbox limit is the initial credit, and adding them is incremental. + +### Security and validation + +| Item | Rule | +| --- | --- | +| Authentication | Same as today: `connection.requestRejection(req)` at upgrade; descriptor and lookup-parameter validation at open. Uplink items belong to an already authenticated stream and are not authenticated again | +| Peer | Admitted at upgrade as one `PeerScope`; `invocation.peer` of every stream on that socket is that Peer | +| Item validation | With an uplink codec, every item is decoded strictly, and a failure fails the whole stream with the existing `gateway/input-invalid` (details `{ endpoint, field: 'uplink' }`); without a codec, only JSON-safety validation applies | +| Frame size | A single frame keeps the `maxPayload` of `ws`; the inbox limit bounds the accumulated total | + +### In-process and worker carriers + +| Carrier | Change | +| --- | --- | +| `ClientConnectionRpc.open` (`packages/client/connection/src/rpc.ts:245-250`) and `RpcStreamOpen` (`client/rpc.ts:21-25`) | Append an optional `uplink?: AsyncIterable`; in process there are no frames and no inbox, the caller's iterator is the source of `uplink()` directly, and backpressure is the pace of the iterator itself | +| Worker tunnel (`packages/experimental/webworker-runtime`) | The page adds `stream-uplink-item` / `stream-uplink-end` frames; `serveStream` assembles them into an `uplink` handed to `seams.openStream`. Same-origin trusted boundary: no inbox limit, and items after `end` are dropped | +| `remote-mock` (`packages/test-support/remote-mock`) | `StreamHandle.uplink: AsyncIterable`; `rpc.open` passes it through | + +An in-process carrier with no `peer` means the operator. + +### Relation to `$events` + +`$events` is a stream on a reserved endpoint; with uplink frames, waterfall answers can become uplink items on the same `streamId`, `clientId` becomes redundant because `streamId` already identifies the generation, and `$events/result` disappears together with the race at `index.ts:527-529`. Out of scope for this Note; see Deferred. + +## Type and wire definitions + +### typert protocol (`packages/typert/protocol/src`) + +```text +// types.ts +export type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } +export type PeerId = Branded<'PeerId'> +export interface PeerScope { readonly id: PeerId; readonly ctx: Context; dispose(): Promise } +export interface RemoteInvocation { … } // 见 Host 面 +declare module '@deepseek-ai/cordis' { interface Context { readonly invocation: RemoteInvocation | undefined } } + +export interface InvocationDescriptor { + // 既有字段不变;mode 仍只有 'stream' + readonly mode?: 'stream' + /** 上行项 codec,从返回类型 RemoteStream 的 In 生成;In 为 never 时缺席。 */ + readonly uplink?: { readonly codec: TypertCodec } + readonly cancellation?: { readonly parameter: 'signal' } + readonly result: TypertCodec +} +``` + +The `Remote` decorator, `RemoteMethodOptions`, and `RemoteMethodMarker` recognize only `mode: 'stream'`. + +### typert generator (`packages/typert/generator/src`) + +- `model.ts`: `InvocationModel.uplink?: { boundary: RemoteBoundaryModel }`. +- `analyzer.ts` `remoteResultType` (currently `~1402`): for `mode: 'stream'`, the accepted return-type wrappers are `Iterable`, `AsyncIterable`, and `RemoteStream`. `RemoteStream` is recognized the same way as the standard library's `AsyncIterable`: by symbol name plus declaring file (`types.ts` of `@deepseek-ai/dsh-typert-protocol`). The first type argument is the downlink item; when the second is present and is not `never`, an `uplink` boundary is generated under the key `${endpoint}:uplink`. +- `emitter.ts`: the descriptor literal emits `uplink: { codec }`; the generated Client signature returns `RemoteStreamHandle`. +- The parameter loop recognizes no parameter named `uplink`. + +### Two names, one entry point + +A Host method declares a stream with `RemoteStream`; the Client holds a `RemoteStreamHandle`. Both are exported from the main entry point of `@deepseek-ai/dsh-typert-protocol`, and the handle interface has no Host dependency. The generated Client contract writes the return type as `RemoteStreamHandle`. One name has one meaning: the `RemoteStream` that Client code obtains from the main entry point is always the declaration type and is never confused with the handle. + +### Gateway Host (`packages/api/gateway/src`) + +```text +// types.ts +export interface InvokeRemoteRequest { + readonly namespace: string + readonly method: string + readonly args: Readonly> + /** 上行项;缺席等价于立即结束的迭代器。 */ + readonly uplink?: AsyncIterable + /** 发起调用的 Peer;缺席即操作者。 */ + readonly peer?: PeerScope + readonly signal?: AbortSignal +} + +export interface TypertGatewayWireStream { + open: (endpoint: string, payload: unknown, uplink: AsyncIterable, peer: PeerScope | undefined, signal: AbortSignal) => Promise> + failure: (error: unknown) => RemoteStreamFailure +} + +// stream-server.ts +export type RemoteStreamOpener = ( + endpoint: string, + payload: unknown, + uplink: AsyncIterable, + peer: PeerScope, + control: AbortController, +) => Promise> + +// index.ts Config 新增 +/** 每条逻辑流的上行 inbox 上限,按帧的 UTF-8 字节计(默认 262144)。 */ +readonly streamInboxBytes?: number +``` + +`prepareInvocation`: + +```text +const args = await Promise.all(descriptor.parameters.map(parameter => this.resolveParameter(parameter, request.args, endpoint))) +const signal = request.signal ?? NEVER_ABORTED_SIGNAL +const invocation = new GatewayInvocation( + { namespace: request.namespace, method: request.method, args: request.args }, + descriptor.service, + request.peer ?? this.operatorPeer(), + signal, + new UplinkDecoder(request.uplink ?? EMPTY_ASYNC_ITERABLE, descriptor.uplink?.codec, endpoint, control), +) +const callReceiver = receiverContext.extend({ invocation }).get(descriptor.service) as object +if (descriptor.cancellation !== undefined) args.push(signal) +``` + +`GatewayInvocation.uplink()` returns the decoder on the first call and throws on the second. `UplinkDecoder` is a hand-written iterator: with a codec it runs `decode(codec, value, endpoint, 'uplink')` per item and, on failure, calls `control.abort(failure)` and throws; without a codec it applies only the `isRemoteJsonValue` check. The `finally` of `cancellableStream` closes the decoder first (`invocation.close()`) and then awaits the method iterator's `return()`, so a method blocked on `uplink.next()` can exit. The mode branches are the existing two, unary and stream. SRC fallback: the descriptor has no `uplink` codec, and `uplink()` delivers `unknown`. + +### Gateway Host mux (`stream-server.ts`) + +```text +interface ActiveStream { readonly control: AbortController; readonly inbox: UplinkInbox; done: Promise } + +/** 有界上行队列;作为 uplink() 的源被迭代。 */ +class UplinkInbox implements AsyncIterable, AsyncIterator { + constructor(maxBytes: number, onOverflow: (error: RemoteError) => void) + push(value: unknown, frameBytes: number): void // ended 后 push 使流以 gateway/protocol 失败;超限调用 onOverflow + end(): void // 幂等 + fail(error: unknown): void // 取消或载体关闭时让迭代器以错误结束 + next() / return() // 手写:next 与失败竞速,return 立即释放 +} +``` + +`receive(text)` dispatch: + +``` +open : 已存在 → 抛错(socket 1008);否则创建 { control, inbox, done },启动 pump +item : 不存在 → 忽略;否则 inbox.push(value, bytes) +end : 不存在 → 忽略;否则 inbox.end() +cancel : 不存在 → 忽略;否则 control.abort(new Error('Remote stream cancelled')) +``` + +`pump` passes the inbox and the socket's Peer to the opener and calls `inbox.fail(...)` in its `finally`. An abort whose reason is a `RemoteError` is sent by the pump as an `error` frame (a failure raised by the Gateway or the mux); an abort whose reason is a plain `Error` (cancel, socket closure) sends no terminal frame. + +### Gateway Client (`packages/api/gateway/src/client`) + +- `prepareInvocation` strips no uplink parameter; the positional parameters are the business parameters plus the optional `signal`. +- `invokeStream` returns a `RemoteStreamHandle`: it holds an uplink queue internally, `send` enqueues and a pump sends the `item` frames, `end` sends the `end` frame, and `dispose` aborts the generation signal. The downlink is still the iterator that `openRemoteStream` returns. +- `RemoteStreamMuxClient.open(endpoint, payload, signal, uplink?)`: after sending the `open` frame it starts the uplink pump, which sends one `item` per item and `end` when the source ends; when the downlink terminates or the signal aborts, it stops the pump and calls the source's `return()` (without awaiting, so a keyboard-style generator cannot hang it); when the source throws, it calls `inbox.fail(error)`, the downlink fails with that error, and `finally` sends `cancel` under the existing rules. The pump checks `this.socket === socket`, so an old generation never sends frames on a new socket. +- In-process carrier: `connection.rpc.open(channel, endpoint, payload, signal, uplink)`. + +## Placement + +| Package | Carries | +| --- | --- | +| `dsh-typert-protocol` (`types.ts`, `index.ts`) | `RemoteStream`, `RemoteStreamHandle`, `PeerId`, `PeerScope`, `RemoteInvocation`, the `ctx.invocation` declaration merge, `InvocationDescriptor.uplink`; the decorator recognizes only `mode: 'stream'`; `TypertRemoteService` registers the `invocation` accessor when constructed | +| `dsh-typert-registry` | Load-time validation that `uplink.codec` is a valid strict codec | +| `dsh-typert-generator` (`model.ts`, `analyzer.ts`, `emitter.ts`) | `remoteResultType` recognizes `RemoteStream` and generates the `uplink` boundary; the descriptor emits `uplink: { codec }`; the generated Client signature returns `RemoteStreamHandle`; the parameter loop accepts only business parameters and a final `signal` | +| `dsh-client-connection` (`operator-peer.ts`, `rpc.ts`, `rpc-host.ts`, `index.ts`) | The operator `PeerScope`; `connection.operator` and `admit`; the `/api` and upgrade routes obtain the Peer through `admit`; the optional `uplink` of `rpc.open` | +| `dsh-api-gateway` Host (`stream-protocol.ts`, `stream-server.ts`, `types.ts`, `index.ts`) | `item` / `end` frames; `UplinkInbox`; `Config.streamInboxBytes`; `GatewayInvocation` and `extend({ invocation })`; `UplinkDecoder`; `handleUpgrade(req, socket, head, peer)`; `operatorPeer()` | +| `dsh-api-gateway` Client (`client/index.ts`, `client/stream-client.ts`) | `invokeStream` returns the handle; the uplink pump is driven by the handle's queue | +| `dsh-webworker-runtime`, `dsh-remote-mock` | The worker tunnel's two uplink frames and the `serveStream` assembly; `StreamHandle.uplink`; the direct proxy returns the real handle | +| Documentation | The READMEs of gateway, the api group, connection, protocol, and generator; `docs/api-gateway`; the type-equiv blocks of `docs/subsystems/typert`; the config and Cordis catalogs | + +## Key sequences + +### Open, uplink, half-close, downlink end + +``` +Client 调用方 mux client mux server gateway Host 方法 + │ attach(req) │ │ │ │ + │───────────────────▶│ open { id, ep, payload } ─────────────────▶│ 创建 inbox │ + │ s.send(v1) │ item { id, v1 } ──────────────────────────▶│ inbox.push(v1) │ + │ │ │ │ prepareInvocation │ + │ │ │ │ extend({ invocation }) │ + │ │ │ │───── method(req, signal) ─────────▶│ + │ │ │ │ │ uplink() → v1 + │ s.send(v2) │ item { id, v2 } ──────────────────────────▶│ inbox.push(v2) │ → v2 + │ s.end() │ end { id } ───────────────────────────────▶│ inbox.end() │ 迭代结束 + │◀── yield o1 ───────│◀────────────────────── item { id, o1 } ────│◀── yield o1 ────────│ + │◀── 结束 ───────────│◀────────────────────── end { id } ─────────│◀── 方法返回 ─────────│ gateway 调 uplink.return() +``` + +### Client dispose + +``` +Client 调用方 mux client mux server gateway Host 方法 + │ s.dispose() │ │ │ │ + │───────────────────▶│ 停泵;源 return()(不等待) │ │ + │◀── 迭代器结束 ─────│ cancel { id } ────────────────────────────▶│ control.abort() │ + │ │ │ │ cancellableStream: iterator.return() ──▶│ finally 清理 + │ │ │ │ uplink.return() │ + │ │ │ 不发终止帧 │ │ +``` + +### inbox overflow + +``` +Client 调用方 mux client mux server gateway / Host 方法 + │ s.send(vN) │ item { id, vN } ─────▶│ inbox.push: bytes > streamInboxBytes │ + │ │ │ onOverflow → control.abort(RemoteError uplink-overflow) + │ │ │ │ uplink() 以该错误结束;方法迭代器 return() + │◀── throw ──────────│◀── error { id, uplink-overflow } ─────────│ pump 因 reason 是 RemoteError 发 error 帧 +``` + +## Boundary matrix + +| Event | opening | running | Uplink ended | Downlink terminated | +| --- | --- | --- | --- | --- | +| `item` received | Buffered in the inbox | Into the inbox | The stream fails with `gateway/protocol` | Ignored | +| `end` received | inbox.end | inbox.end | Idempotent | Ignored | +| `cancel` received | abort | abort | abort | Ignored | +| inbox overflow | The stream fails with `uplink-overflow` | Same | Impossible | Impossible | +| Decode failure | Impossible | The stream fails with `input-invalid` | Same | Impossible | +| Host method finishes | Impossible | Sends `end`; `uplink.return()` | Sends `end` | Already sent | +| Host method throws | Sends `error` | Sends `error`; `uplink.return()` | Sends `error` | Already sent | +| Socket closes | abort, await done | Same | Same | Nothing | +| Client `send` | Queued, sent after open | Sent | Throws | Throws | +| Client `end` | Recorded, sent after open | Sent | Idempotent | Ignored | +| Client-side downlink ends first | Impossible | Stop the pump; source `return()`; no `end` sent | Nothing | Nothing | + +## Error codes + +| Code | When | +| --- | --- | +| `gateway/input-invalid` | Existing: a parameter codec fails to decode; new: an uplink item fails to decode | +| `gateway/uplink-overflow` | New: the inbox exceeds `streamInboxBytes` | +| `gateway/protocol` | New: an `item` after `end` | +| `gateway/cancelled` | Existing: `signal` aborted | + +New codes are registered in `packages/api/gateway/src/remote-error-codes.ts` and in the README's error-code section. + +## Minimal echo method + +`packages/api/gateway/tests/echo-stream.host.spec.ts` runs the same method through the WebSocket carrier and through the in-process carrier: + +```text +class EchoService extends TypertRemoteService { + constructor(ctx: Context) { super(ctx, 'echo', { namespace: 'echo' }) } + + @Remote({ mode: 'stream' }) + async *echo(prefix: string, signal: AbortSignal): RemoteStream { + for await (const item of this.ctx.invocation!.uplink()) { + signal.throwIfAborted() + yield `${prefix}${item}` + } + } +} + +// 客户端 +const stream = remote.echo.echo('> ') +stream.send('a'); stream.send('b'); stream.end() +const replies: string[] = [] +for await (const reply of stream) replies.push(reply) // ['> a', '> b'] +``` + +## Alternatives considered + +**A separate `mode: 'duplex'` plus a reserved parameter `uplink: AsyncIterable` alongside `signal`.** Special logic everywhere: the analyzer must recognize a second reserved parameter and its position rules, the generated signature gains a parameter, load time must validate `duplex ⟺ uplink`, and because the mux does not know the method mode, the Gateway must reject uplink frames on behalf of `'stream'` methods. Duplex is an inherent property of the transport-layer stream, not a mode of the method: the carrier already has `streamId`, `open`, and `cancel`; the uplink adds just two more frames, which any stream can receive and which are simply not sent when unused. + +**Declaring the uplink type in a decorator option, `@Remote({ mode: 'stream', uplink: 'JobInputFrame' })`.** A string reference to a type name is not checked by the compiler and breaks on rename. The return-type alias puts both types in one compiler-visible place. + +**Passing a schema at runtime: `uplink(schema)`.** Bypasses the generator, the Client loses type projection, and it splits from the "strict codec per item" system. The codec still comes from the generator; the generic in the method is only an assertion. + +**Having the analyzer cross-check the hand-written generic of `uplink()` against the return type.** A business-specific check whose mistakes the caller bears, consistent with the other hand-written assertions in the repository; not added. + +**A transport-layer handle with reopening, cursors, and a `reopen` hook.** Reopening after disconnection belongs to the upper-layer protocol: a job resumes through an `opened` frame carrying `from` and a client that remembers `next` and calls again; snapshot and journal each have their own anchors and classification. The transport layer knows one generation. + +**Putting the Client's `send` on the `AsyncIterable` the generated method returns.** Hanging methods on an `AsyncIterable` is unconventional; the handle type `RemoteStreamHandle` extends it explicitly. + +**Copying the Access mechanism.** Access control is business semantics, served by `vouch` and the `remote/invoke` waterfall; this Note only needs "whose stream is this", and `PeerScope` suffices. + +**Keeping unary RPC for the uplink.** One HTTP round trip, one authentication, and one lookup per uplink; two carriers with no ordering relation that only Client-side serialization can order; no EOF; cancellation that does not cover both halves; a hand-made correlation key per feature. + +**A native HTTP request body stream.** A one-shot request body with no association to the downlink stream, bypassing Typert descriptors, and browser support for full-duplex fetch cannot be relied on. + +**One dedicated WebSocket per duplex stream.** Loses the authentication, heartbeat, and multiplexing shared with the existing downlink streams, and every feature manages its own socket lifecycle. + +**WebTransport or HTTP/2 bidirectional streams.** They have per-stream flow control, but support on the browser and Node sides, proxy traversal, and the Electron carrier are all immature; adding two frames to the existing mux is orders of magnitude cheaper than replacing the transport layer. + +## Consequences + +- **Bought**: one stream with two directions, so interactive scenarios such as terminal keystrokes, approval answers, and background-job stdin no longer each build their own "one stream plus one unary"; a Host method knows its caller; the uplink and downlink types are declared in one place, the return type, and uplink items are validated per item as strictly as downlink items; the generated output and wire frames of existing `AsyncIterable` methods and unary methods are completely unchanged. +- **`this.ctx.invocation` is `undefined` outside a Remote call**, so a method that reads it must handle the optional; a service method invoked only directly in process correctly reads `undefined`. +- **The inbox limit applies to every stream**: a method that does not read its uplink fails as a whole when it receives many uplink frames. This is a deliberate explicit failure, recorded in the README. +- **The operator is the only Peer**, and `admit` is the only admission point; the first consumer that needs a second Peer must add opening, association, and events in `dsh-client-connection`. +- **No cross-direction ordering guarantee between uplink and downlink**; a protocol that needs request-response pairing carries its own sequence numbers. +- **Upper-layer protocols do not yet expose `send`**: consumers of `$stream`, snapshot, and journal use only the downlink today; the object they receive is already the handle. +- **The worker tunnel behaves differently from the WebSocket carrier**: no inbox limit, and items after `end` are dropped, because page to worker is a same-origin trusted boundary. + +## Testing + +- `packages/api/gateway/tests/echo-stream.host.spec.ts`: the same echo method passes through the WebSocket carrier and through the in-process carrier. +- Gateway Host: exact-key validation in frame parsing; `UplinkInbox` buffering during opening, `item` after `end`, overflow, ignored unknown `streamId`, and cleanup on socket closure; `UplinkDecoder` delivery with and without a codec and abort on decode failure; `uplink()` taken only once; the `extend({ invocation })` view and the origin of `peer`; `cancellableStream` closing the uplink before `return()`. +- Gateway Client: the handle's `send` (JSON validation, queued before open, throws after termination), idempotent `end`, `dispose` driving the `cancel` frame and ending later reads; the pump's three exits; the pump stopping as soon as a terminal frame arrives. +- typert: the analyzer's handling of `RemoteStream`, `RemoteStream`, `never`, and the existing `AsyncIterable`; decorator options; registry load-time validation. +- connection: the three outcomes of `admit`, 401, 403, and the operator; the operator reaching handlers through the bridge and the shared handler; release with the Connection. +- remote-mock and the worker tunnel: uplink pass-through; the direct proxy returning the real handle. +- Existing stream and unary method tests pass without changed expectations. + +## Deferred + +- `$stream` / snapshot / journal expose the handle's `send` to upper-layer protocols. +- Web terminal: one `attach` stream replaces `follow` plus `write` plus `resize`, and `attachmentId` retires. +- Duplex `$events`: waterfall answers become uplink items on the same `streamId`, and `$events/result` and `clientId` retire. +- Credit frames: wait for a consumer that needs sustained high-volume uplink; the inbox limit is the initial credit. +- The `streamInboxBytes` default of 262144 is far more than keyboard and stdin need; if the first consumer is terminal paste, it may be aligned with the `maxInputBytes` of `terminal-controller`. +- A Host-initiated half-close frame (the Host stops reading the uplink but keeps writing the downlink): no consumer; not added. +- `uplink()` on unary methods: it can read items that arrive while the method runs, semantically valid but of doubtful use; allowed, noted in the README. + +## Related + +- [Remote event delivery](2026-08-10-remote-event-delivery.md): the unary `$events/result` answer path; the duplex `$events` item under Deferred partially supersedes it once it lands. +- [Session history and event transport](2026-08-18-session-history-and-event-transport.md): the `$stream()` supervisor and the journal / snapshot protocols above it, which this note leaves unchanged. +- [Web sidebar terminal](../feature/2026-09-09-web-sidebar-terminal.md): the unary terminal `write` and `attachmentId`; the `attach` stream under Deferred replaces it once it lands. diff --git a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md index fc5e43db2e..5fee4d934f 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md @@ -100,7 +100,7 @@ Host 方法也没有"这次调用是谁发起的"这个概念:`InvokeRemoteReq * Client 面上生成方法返回的是 RemoteStreamHandle;两个名字各自只有一个含义。 * In 是客户端可上行的项类型;缺省 never 表示该方法不读上行。 */ -export type RemoteStream = AsyncIterable +export type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } // Host @Remote({ mode: 'stream' }) @@ -315,7 +315,7 @@ credit 帧留待需要持续大流量上行的消费者出现时再加;inbox ```text // types.ts -export type RemoteStream = AsyncIterable +export type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } export type PeerId = Branded<'PeerId'> export interface PeerScope { readonly id: PeerId; readonly ctx: Context; dispose(): Promise } export interface RemoteInvocation { … } // 见 Host 面 @@ -587,3 +587,9 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b'] - `streamInboxBytes` 默认值 262144 对键盘与 stdin 远超需要;若第一个消费者是终端粘贴,可能与 `terminal-controller` 的 `maxInputBytes` 对齐。 - Host 发起的半关闭帧(Host 不再读上行但继续写下行):没有消费者,不加。 - unary 方法的 `uplink()`:能读到方法运行期间到达的项,语义成立但用处存疑;允许,README 注明。 + +## 相关 + +- [Remote 事件投递](2026-08-10-remote-event-delivery.zh.md):`$events/result` 的 unary 回答路径;「后续」里的双工 `$events` 落地后部分取代它。 +- [会话历史与事件传输](2026-08-18-session-history-and-event-transport.zh.md):`$stream()` 监督器与其上的 journal / snapshot 协议,本 Note 不改它们。 +- [Web 侧边栏终端](../feature/2026-09-09-web-sidebar-terminal.zh.md):终端按键的 unary `write` 与 `attachmentId`;「后续」里的 `attach` 流落地后取代它。 From 755f022ef038aff7dc824e824816b5a6b97c233e Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 20 Sep 2026 13:57:50 +0800 Subject: [PATCH 27/60] docs: regenerate catalogs and document the mock uplink handle --- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.md | 4 ++-- docs/config-catalog.zh.md | 4 ++-- docs/event-producer-consumer.i18n.yaml | 4 ++-- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- packages/test-support/remote-mock/src/streams.ts | 7 +++++++ 7 files changed, 17 insertions(+), 10 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index cde8b8f8f4..b9fe4f9f65 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: f8ccc54835146648f8c090e274bfa27af88471e8 -config-catalog.zh.md: 76e5c6f81fa6bebad3502530a921d3a0d224a0f6 +config-catalog.md: 2927b510976631695978f46ed3c012641b4d73a9 +config-catalog.zh.md: 6d57dbe759fee5eaea9e755731fcf22666e8f0f3 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f8ccc54835..2927b51097 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -199,7 +199,7 @@ export interface Config { } ``` -Source: [`packages/api/gateway/src/index.ts:141`](../packages/api/gateway/src/index.ts) +Source: [`packages/api/gateway/src/index.ts:140`](../packages/api/gateway/src/index.ts) @@ -430,7 +430,7 @@ export interface ConnectionRecoveryConfig { } ``` -Source: [`packages/client/connection/src/index.ts:91`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:90`](../packages/client/connection/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 76e5c6f81f..6d57dbe759 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -201,7 +201,7 @@ export interface Config { } ``` -来源:[`packages/api/gateway/src/index.ts:141`](../packages/api/gateway/src/index.ts) +来源:[`packages/api/gateway/src/index.ts:140`](../packages/api/gateway/src/index.ts) @@ -432,7 +432,7 @@ export interface ConnectionRecoveryConfig { } ``` -来源: [`packages/client/connection/src/index.ts:91`](../packages/client/connection/src/index.ts) +来源: [`packages/client/connection/src/index.ts:90`](../packages/client/connection/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 9bd87adc80..88b786a588 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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/event-producer-consumer.md -event-producer-consumer.md: a0bf6543c2d97c68d85eaafd94f0049660ab34a9 -event-producer-consumer.zh.md: bde5a953db35254c30254efe0f5f5209fc12870b +event-producer-consumer.md: 619103ab6f813fc9ea915cc4f6bf5b6ae585f8cb +event-producer-consumer.zh.md: 00a13ca2aa785dd253263f154627e6dd8accacc6 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index a0bf6543c2..619103ab6f 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -30,7 +30,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | | `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:106`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | -| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:66`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | +| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:65`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index bde5a953db..00a13ca2aa 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -32,7 +32,7 @@ | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | | `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:106`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | -| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:66`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | +| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:65`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | diff --git a/packages/test-support/remote-mock/src/streams.ts b/packages/test-support/remote-mock/src/streams.ts index 04b2efdcea..6359cfdfe9 100644 --- a/packages/test-support/remote-mock/src/streams.ts +++ b/packages/test-support/remote-mock/src/streams.ts @@ -245,6 +245,11 @@ export class HandleUplink implements AsyncIterable { private done = false private waiter: ((result: IteratorResult) => void) | undefined + /** + * Queues one uplink item for the script, or completes the read waiting for it. + * @param item - the item the handle's `send()` carried. + * @throws {Error} after `end()`. + */ push(item: unknown): void { if (this.done) throw new Error('remote-mock: uplink was ended') const waiter = this.waiter @@ -253,6 +258,7 @@ export class HandleUplink implements AsyncIterable { else waiter({ value: item, done: false }) } + /** Half-closes the uplink: pending and later reads complete as done; repeated calls are no-ops. */ end(): void { if (this.done) return this.done = true @@ -260,6 +266,7 @@ export class HandleUplink implements AsyncIterable { this.waiter = undefined } + /** Ends the uplink and drops its unread items; `dispose()` on the handle and `return()` on the script's iterator call it. */ close(): void { this.items.length = 0 this.end() From 520c225b87c8b048530c20528cbbbdd06fc7b9fe Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 20 Sep 2026 14:19:31 +0800 Subject: [PATCH 28/60] test(gateway): cover the uplink edge paths --- .../gateway/tests/gateway-stream.host.spec.ts | 17 +++++ .../api/gateway/tests/gateway.client.spec.ts | 64 ++++++++++++++++++- .../remote-mock/tests/rpc.client.spec.ts | 26 ++++++-- 3 files changed, 99 insertions(+), 8 deletions(-) diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index ee90ce9ab6..74e2edf3e3 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -173,6 +173,15 @@ class FeedService extends TypertRemoteService { for await (const item of this.invocation().uplink()) yield item } + /** Reads the uplink to its end and then again: an ended uplink reports done on every later read. */ + @Remote({ mode: 'stream' }) + async *rereads(): RemoteStream { + const uplink = this.invocation().uplink() + for await (const item of uplink) yield item + for await (const item of uplink) yield `again:${item}` + yield 'done' + } + /** Reports the call context; its descriptor declares no uplink codec, so items arrive as `unknown`. */ @Remote({ mode: 'stream' }) async *context(label: string): RemoteStream { @@ -483,6 +492,13 @@ describe('Typert Remote streams', () => { await expect(iterator.return?.()).resolves.toEqual({ done: true, value: undefined }) }) + it('reports done on every read after the uplink ended', async () => { + const { ctx } = await setup(false) + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'rereads', args: {}, uplink: toAsync(['a', 'b']), + }))).resolves.toEqual(['a', 'b', 'done']) + }) + it('releases the uplink when a unary method is opened through the stream carrier', async () => { const { ctx } = await setup(false) const returned = vi.fn(async (): Promise> => ({ value: undefined, done: true })) @@ -1319,6 +1335,7 @@ function descriptors(): InvocationDescriptor[] { withUplink('hold', []), withUplink('peek', []), withUplink('drain', [], false), + withUplink('rereads', [], false), stream('context', [label], z.string()), { ...stream('follow', [label], z.string()), cancellation: { parameter: 'signal' } }, stream('sync', [label], z.string()), diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index 14b6cbb7c1..a5fedf67d4 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -2626,6 +2626,63 @@ describe('Client Typert API', () => { }) }) + it('ignores server frames for a stream it no longer tracks', async () => { + await withFakeWebSocket('https://harness.example/', async () => { + const ctx = await bench(vi.fn(), 'web') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const alpha = ctx.remote.probe.attach('alpha') + const socket = FakeWebSocket.sockets[0]! + await vi.waitFor(() => { expect(socket.sent).toHaveLength(1) }) + const alphaId = (JSON.parse(socket.sent[0]!) as { streamId: string }).streamId + const beta = ctx.remote.probe.attach('beta') + await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) }) + const betaId = (JSON.parse(socket.sent[1]!) as { streamId: string }).streamId + alpha.dispose() + await vi.waitFor(() => { expect(socket.sent).toHaveLength(3) }) + // The Host's end for alpha crosses the cancel on the wire, so no stream owns that id any more; a + // never-opened id is just as unknown. Neither frame disturbs beta or the socket. + socket.receive({ type: 'end', streamId: alphaId }) + socket.receive({ type: 'item', streamId: 'never-opened', value: 'stray' }) + socket.receive({ type: 'item', streamId: betaId, value: 'beta:1' }) + socket.receive({ type: 'end', streamId: betaId }) + await expect(drainStream(beta)).resolves.toEqual(['beta:1']) + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'open', 'cancel']) + expect(socket.closedWith).toEqual([]) + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + }) + + it('swallows a carrier iterator whose return() rejects on dispose', async () => { + // One item, then silence: dispose reaches the carrier's return() while the stream is suspended at that item. + const next = vi.fn<() => Promise>>() + .mockResolvedValueOnce({ value: 'alpha:1', done: false }) + .mockReturnValue(new Promise>(() => {})) + const returned = vi.fn<() => Promise>>() + .mockRejectedValueOnce(new Error('carrier return failed')) + .mockResolvedValue({ value: undefined, done: true }) + const open: NonNullable = () => ({ + [Symbol.asyncIterator]: () => ({ next, return: returned }), + }) + const { ctx, client } = await benchFiber(vi.fn(), 'in-process', open) + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const handle = ctx.remote.probe.attach('alpha') + const iterator = handle[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'alpha:1' }) + handle.dispose() + await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) + // The rejection is swallowed (an unhandled rejection would fail this test) and the iteration ends quietly. + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + } finally { + await dispose() + await client.dispose() + } + }) + it('refuses an uplink item that is not a lossless JSON value', async () => { const mock = RemoteMock.create().stream('probe/attach', async (args, stream) => { const [{ topic }] = args as [{ readonly topic: string }] @@ -2635,9 +2692,10 @@ describe('Client Typert API', () => { const { ctx, client } = await benchFiber(vi.fn(), 'in-process', mock.rpc.open) const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) try { - const handle = ctx.remote.probe.attach('alpha') - expect(() => { handle.send(1n as unknown as string) }).toThrow('client api: probe/attach uplink item is not a lossless JSON value') - expect(() => { handle.send(Number.NaN as unknown as string) }).toThrow('is not a lossless JSON value') + // Widened to the loosest caller `send` accepts: the check is on the value, not on the generated type. + const handle: RemoteStreamHandle = ctx.remote.probe.attach('alpha') + expect(() => { handle.send(1n) }).toThrow('client api: probe/attach uplink item is not a lossless JSON value') + expect(() => { handle.send(Number.NaN) }).toThrow('is not a lossless JSON value') handle.send('ok') handle.end() await expect(drainStream(handle)).resolves.toEqual(['alpha:ok']) diff --git a/packages/test-support/remote-mock/tests/rpc.client.spec.ts b/packages/test-support/remote-mock/tests/rpc.client.spec.ts index da1330df8d..fd83e0ecf1 100644 --- a/packages/test-support/remote-mock/tests/rpc.client.spec.ts +++ b/packages/test-support/remote-mock/tests/rpc.client.spec.ts @@ -3,6 +3,11 @@ import { describe, expect, it } from 'vitest' import { RemoteMock, ok, openStream, type MockClientStream } from '../src/index.ts' const idle = (): AbortSignal => new AbortController().signal +const drain = async (source: AsyncIterable): Promise => { + const items: unknown[] = [] + for await (const item of source) items.push(item) + return items +} describe('RemoteMock.rpc', () => { it('calls and opens by endpoint, taking the proxies\' positional args or the Gateway\'s single object', async () => { @@ -23,11 +28,6 @@ describe('RemoteMock.rpc', () => { for await (const item of stream.uplink) stream.push(`${String(args[0])}:${String(item)}`) stream.end() }) - const drain = async (source: AsyncIterable): Promise => { - const items: unknown[] = [] - for await (const item of source) items.push(item) - return items - } const uplink = (values: readonly string[]): AsyncIterable => (async function *() { yield* values })() await expect(drain(mock.rpc.open!('/api', 'job/attach', { args: ['job-1'] }, idle(), uplink(['a', 'b'])))) @@ -50,6 +50,22 @@ describe('RemoteMock.rpc', () => { await expect(drain(carried)).resolves.toEqual([]) }) + it('queues items sent before the script reads and drops the rest once it stops reading', async () => { + const mock = RemoteMock.create().stream('job/attach', async (args, stream) => { + await Promise.resolve() + for await (const item of stream.uplink) { + stream.push(`${String(args[0])}:${String(item)}`) + break + } + stream.end() + }) + const direct = mock.open('job/attach', ['job-6'], idle()) as MockClientStream + direct.send('first') + direct.send('second') + await expect(drain(direct)).resolves.toEqual(['job-6:first']) + expect(() => { direct.send('late') }).toThrow('remote-mock: uplink was ended') + }) + it('rejects malformed payloads and unmatched endpoints, logging the miss', async () => { const mock = RemoteMock.create() await expect(mock.rpc.call('/api', 'a/b', 'bare')).rejects.toThrow('remote-mock: payload of a/b must be { args: unknown[] | object }') From 6872651527e963c467f35d899a8cc2ad16cd03d1 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 20 Sep 2026 14:58:33 +0800 Subject: [PATCH 29/60] docs: regenerate the module graph --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 23 +++++++++++++---------- docs/module-graph.zh.md | 23 +++++++++++++---------- 3 files changed, 28 insertions(+), 22 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 7c3f9e3842..dcaee90607 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: fdf66c998a1c108cbb32f273c1b40f6a1e4e08b9 -module-graph.zh.md: 980c7a342aff850a41d33c720a9bdec81a98c114 +module-graph.md: 684f82c87648bbd6fa8a1e7f45405a2c2d4d9885 +module-graph.zh.md: cf3bca4f97c840a052399bd3765e7cf11270c396 diff --git a/docs/module-graph.md b/docs/module-graph.md index fdf66c998a..684f82c876 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -414,16 +414,11 @@ flowchart TD pkg_credentials --> pkg_invariants pkg_experimental_inspector --> pkg_client_modules pkg_experimental_inspector --> pkg_host_webserver - pkg_experimental_webworker_runtime --> pkg_client_connection - pkg_experimental_webworker_runtime --> pkg_client_modules - pkg_experimental_webworker_runtime --> pkg_host_webserver pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse pkg_host_directory_picker_auto --> pkg_host_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_webserver - pkg_host_frontend_static --> pkg_client_connection - pkg_host_frontend_static --> pkg_host_webserver pkg_anonymous_user_id --> pkg_brand pkg_anonymous_user_id --> pkg_home_paths pkg_lsp --> pkg_brand @@ -433,6 +428,7 @@ flowchart TD pkg_storage_json --> pkg_storage pkg_storage_sqlite --> pkg_storage pkg_subprocess --> pkg_http_proxy + pkg_remote_mock --> pkg_typert_protocol pkg_typert_loader --> pkg_typert_registry pkg_session --> pkg_scope pkg_system_prompt --> pkg_invariants @@ -451,6 +447,7 @@ flowchart TD pkg_api_terminal_controller --> pkg_subprocess pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths + pkg_client_connection --> pkg_scope pkg_client_file_upload --> pkg_scope pkg_authorization --> pkg_credentials pkg_authorization --> pkg_invariants @@ -468,9 +465,15 @@ flowchart TD pkg_spill --> pkg_brand pkg_spill --> pkg_llm pkg_spill --> pkg_session + pkg_api_gateway --> pkg_client_connection pkg_app_boot --> pkg_home_paths pkg_app_boot --> pkg_launch_environment pkg_app_boot --> pkg_system_prompt + pkg_experimental_webworker_runtime --> pkg_client_connection + pkg_experimental_webworker_runtime --> pkg_client_modules + pkg_experimental_webworker_runtime --> pkg_host_webserver + pkg_host_frontend_static --> pkg_client_connection + pkg_host_frontend_static --> pkg_host_webserver pkg_persona --> pkg_system_prompt pkg_sandbox --> pkg_llm pkg_sandbox --> pkg_session @@ -1322,14 +1325,12 @@ flowchart TD | [`util-workspace-path`](../packages/util/workspace-path) | `util` | — | | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | -| [`api-gateway`](../packages/api/gateway) | `api` | — | | [`api-workspace-files`](../packages/api/workspace-files) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | | [`base`](../packages/bundle/base) | `bundle` | — | | [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | — | | [`sdk-minimal`](../packages/bundle/sdk-minimal) | `bundle` | — | -| [`client-connection`](../packages/client/connection) | `client` | — | | [`client-hmr`](../packages/client/hmr) | `client` | — | | [`client-locale`](../packages/client/locale) | `client` | — | | [`client-modules`](../packages/client/modules) | `client` | — | @@ -1401,7 +1402,6 @@ flowchart TD | [`storage`](../packages/storage/storage) | `storage` | — | | [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — | | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — | -| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | — | | [`typert-generator`](../packages/typert/generator) | `typert` | — | | [`typert-protocol`](../packages/typert/protocol) | `typert` | — | | [`typert-registry`](../packages/typert/registry) | `typert` | — | @@ -1412,15 +1412,14 @@ flowchart TD | [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | -| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | -| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) | +| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | [`typert-protocol`](../packages/typert/protocol) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) | | [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1431,6 +1430,7 @@ flowchart TD | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | +| [`client-connection`](../packages/client/connection) | `client` | [`scope`](../packages/core/scope) | | [`client-file-upload`](../packages/client/file-upload) | `client` | [`scope`](../packages/core/scope) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | @@ -1440,7 +1440,10 @@ flowchart TD | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`skill`](../packages/skill/skill) | | [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | +| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | +| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | | [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-format-catalog`](../packages/session/session-format-catalog) | `session` | [`session`](../packages/core/session) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 980c7a342a..cf3bca4f97 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -416,16 +416,11 @@ flowchart TD pkg_credentials --> pkg_invariants pkg_experimental_inspector --> pkg_client_modules pkg_experimental_inspector --> pkg_host_webserver - pkg_experimental_webworker_runtime --> pkg_client_connection - pkg_experimental_webworker_runtime --> pkg_client_modules - pkg_experimental_webworker_runtime --> pkg_host_webserver pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse pkg_host_directory_picker_auto --> pkg_host_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_webserver - pkg_host_frontend_static --> pkg_client_connection - pkg_host_frontend_static --> pkg_host_webserver pkg_anonymous_user_id --> pkg_brand pkg_anonymous_user_id --> pkg_home_paths pkg_lsp --> pkg_brand @@ -435,6 +430,7 @@ flowchart TD pkg_storage_json --> pkg_storage pkg_storage_sqlite --> pkg_storage pkg_subprocess --> pkg_http_proxy + pkg_remote_mock --> pkg_typert_protocol pkg_typert_loader --> pkg_typert_registry pkg_session --> pkg_scope pkg_system_prompt --> pkg_invariants @@ -453,6 +449,7 @@ flowchart TD pkg_api_terminal_controller --> pkg_subprocess pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths + pkg_client_connection --> pkg_scope pkg_client_file_upload --> pkg_scope pkg_authorization --> pkg_credentials pkg_authorization --> pkg_invariants @@ -470,9 +467,15 @@ flowchart TD pkg_spill --> pkg_brand pkg_spill --> pkg_llm pkg_spill --> pkg_session + pkg_api_gateway --> pkg_client_connection pkg_app_boot --> pkg_home_paths pkg_app_boot --> pkg_launch_environment pkg_app_boot --> pkg_system_prompt + pkg_experimental_webworker_runtime --> pkg_client_connection + pkg_experimental_webworker_runtime --> pkg_client_modules + pkg_experimental_webworker_runtime --> pkg_host_webserver + pkg_host_frontend_static --> pkg_client_connection + pkg_host_frontend_static --> pkg_host_webserver pkg_persona --> pkg_system_prompt pkg_sandbox --> pkg_llm pkg_sandbox --> pkg_session @@ -1324,14 +1327,12 @@ flowchart TD | [`util-workspace-path`](../packages/util/workspace-path) | `util` | — | | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | -| [`api-gateway`](../packages/api/gateway) | `api` | — | | [`api-workspace-files`](../packages/api/workspace-files) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | | [`base`](../packages/bundle/base) | `bundle` | — | | [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | — | | [`sdk-minimal`](../packages/bundle/sdk-minimal) | `bundle` | — | -| [`client-connection`](../packages/client/connection) | `client` | — | | [`client-hmr`](../packages/client/hmr) | `client` | — | | [`client-locale`](../packages/client/locale) | `client` | — | | [`client-modules`](../packages/client/modules) | `client` | — | @@ -1403,7 +1404,6 @@ flowchart TD | [`storage`](../packages/storage/storage) | `storage` | — | | [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — | | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — | -| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | — | | [`typert-generator`](../packages/typert/generator) | `typert` | — | | [`typert-protocol`](../packages/typert/protocol) | `typert` | — | | [`typert-registry`](../packages/typert/registry) | `typert` | — | @@ -1414,15 +1414,14 @@ flowchart TD | [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | -| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | -| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) | +| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | [`typert-protocol`](../packages/typert/protocol) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) | | [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1433,6 +1432,7 @@ flowchart TD | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | +| [`client-connection`](../packages/client/connection) | `client` | [`scope`](../packages/core/scope) | | [`client-file-upload`](../packages/client/file-upload) | `client` | [`scope`](../packages/core/scope) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | @@ -1442,7 +1442,10 @@ flowchart TD | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`skill`](../packages/skill/skill) | | [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | +| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | +| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | | [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-format-catalog`](../packages/session/session-format-catalog) | `session` | [`session`](../packages/core/session) | From 5a8d3b7498ae5903ec32fe842e515ed1c470310a Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 20 Sep 2026 15:00:51 +0800 Subject: [PATCH 30/60] docs(agents): state the uplink note in English on both sides and cross-link its neighbours --- ...2026-08-10-remote-event-delivery.i18n.yaml | 4 +- .../2026-08-10-remote-event-delivery.md | 4 + .../2026-08-10-remote-event-delivery.zh.md | 4 + .../2026-09-19-remote-duplex-stream.i18n.yaml | 4 +- .../2026-09-19-remote-duplex-stream.md | 192 +++++++++--------- .../2026-09-19-remote-duplex-stream.zh.md | 192 +++++++++--------- .../2026-09-09-web-sidebar-terminal.i18n.yaml | 4 +- .../2026-09-09-web-sidebar-terminal.md | 4 + .../2026-09-09-web-sidebar-terminal.zh.md | 4 + 9 files changed, 216 insertions(+), 196 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml index 0e4340ea02..7606190f02 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.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 .agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md -2026-08-10-remote-event-delivery.md: 1706cc525bd7707874353a6658ec2f52597044a9 -2026-08-10-remote-event-delivery.zh.md: ac79642e90791892870c8920e02867faea180b07 +2026-08-10-remote-event-delivery.md: 3eaa26fb92c40b3a20cab11d4522bcf68b8a207f +2026-08-10-remote-event-delivery.zh.md: d41589f1b800da98342210d2e429a2b67b6c61f2 diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md index 1706cc525b..3eaa26fb92 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md @@ -195,3 +195,7 @@ The few required Client symbols are mirrored on the test side: `scaffold.ts` exp - **Capabilities deliberately omitted.** Payload projection and redaction are unsupported, scopes other than Agent are unsupported, and ordinary notifications are not replayed. Recoverable state needs a query, cursor, or opening baseline; a waterfall is replayed only while its original Host invocation remains pending. - **Some Client packages remain in the Host graph.** Twelve projects, including `connection`, `runtime`, and `ui-slots`, remain reachable through unsplit `directory-picker-browse`/`-native` and `api/gateway → client/connection`. They compile and no longer pull in API Remotes' Client face, so this change does not split them. Direct `dsh-client-runtime/client` imports in two chat e2e files rely on Runtime's current presence in that graph rather than a general guarantee. - **The package intentionally publishes no invariant companion.** A prior revision asserted delivery form on the live event bus, coupling diagnostics to the allowlist and causing Rolldown to emit a third bundle chunk omitted by the mechanically derived publication list. The Host-face `TypertForwardableEventEntry` assertion already rejects those mismatches at compile time, and the package README records why no independent runtime relation remains. + +## Related + +- [Remote duplex stream](2026-09-19-remote-duplex-stream.md): every Remote stream carries a Client uplink on the same logical stream; the duplex `$events` listed under its Deferred section replaces the unary `$events/result` path once it lands. diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md index ac79642e90..d41589f1b8 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md @@ -197,3 +197,7 @@ Client 要求首项是带非空 `clientId` 与 `host.home` 的 `ready`;后续 - **放弃的能力**:不支持投影或脱敏载荷,不支持 Agent 以外的 Scope,也不为普通通知提供重放。需要可靠恢复的状态必须拥有查询、cursor 或 opening baseline;waterfall 只重放仍处于同一次 Host 调用生命周期内的 pending request。 - **仍有 client 包留在 host 图里**:12 个工程(`connection`、`runtime`、`ui-slots` 等)经未拆分的 `directory-picker-browse`/`-native` 与 `api/gateway → client/connection` 仍可达 host 图。它们都能编译且不再牵连 api/remotes 的 client face,因此没有阻塞本次改动;拆分那些包能减少几个,但经评估后不做。两个 chat e2e 直接引 `dsh-client-runtime/client` 依赖 `runtime` 本来就在图里——属偶然而非保证。 - **本包不发布 invariant companion**:早先的修订曾在活事件总线上断言投递形状(`thisArg === null`、`mode === 'emit'`),这让诊断逻辑与名单值耦合,并使 rolldown 把它提成第三个 bundle chunk——而机械推导的发布文件清单并不携带它。Host 面的 `TypertForwardableEventEntry` 断言已在编译期拒绝这些偏离,包 README 也记录了不再存在独立运行时关系的原因。 + +## 相关 + +- [Remote 双工流](2026-09-19-remote-duplex-stream.zh.md):所有 Remote 流在同一逻辑流上承载客户端上行;其「后续」列出的双工 `$events` 落地后取代 unary `$events/result` 路径。 diff --git a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml index 6981faa8db..d5903d0fc2 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.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 .agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md -2026-09-19-remote-duplex-stream.md: bbfcc7763333b75f41df37bb187da38c9c4b261e -2026-09-19-remote-duplex-stream.zh.md: 5fee4d934fdba46db2e455e56fd4aaf20d50d575 +2026-09-19-remote-duplex-stream.md: 0f1f6a575f6871d37822c5829fe4531261b8e95b +2026-09-19-remote-duplex-stream.zh.md: d3982aa433aa3ce84b945bb5831cef8064472853 diff --git a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md index bbfcc77633..0f1f6a575f 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md +++ b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md @@ -8,29 +8,29 @@ English | [中文](2026-09-19-remote-duplex-stream.zh.md) ### Current state -`dsh-api-gateway` multiplexes every Typert Remote stream over one WebSocket at the fixed path `/api/remote.mux` (`packages/api/gateway/src/stream-protocol.ts:6`). A Host method decorated with `@Remote({ mode: 'stream' })` that returns an `Iterable` or `AsyncIterable` becomes one Host-to-Client logical stream; the optional final `signal: AbortSignal` is the only reserved parameter, never enters the wire arguments, and is appended by the Gateway after the decoded business parameters (`packages/api/gateway/src/index.ts:613`). The generated method the Client receives returns a bare `AsyncIterable`. +`dsh-api-gateway` multiplexes every Typert Remote stream over one WebSocket at the fixed path `/api/remote.mux` (`packages/api/gateway/src/stream-protocol.ts`). A Host method decorated with `@Remote({ mode: 'stream' })` that returns an `Iterable` or `AsyncIterable` becomes one Host-to-Client logical stream; the optional final `signal: AbortSignal` is the only reserved parameter, never enters the wire arguments, and is appended by the Gateway after the decoded business parameters (`packages/api/gateway/src/index.ts`). The generated method the Client receives returns a bare `AsyncIterable`. There are only five wire frames: | Direction | Frame | Definition | | --- | --- | --- | -| Client to Host | `{ type: 'open', streamId, endpoint, payload }` | `stream-protocol.ts:243-249` | -| Client to Host | `{ type: 'cancel', streamId }` | `stream-protocol.ts:250` | -| Host to Client | `{ type: 'item', streamId, value? }` | `stream-protocol.ts:261` | -| Host to Client | `{ type: 'error', streamId, error: { code, message, details } }` | `stream-protocol.ts:262` | -| Host to Client | `{ type: 'end', streamId }` | `stream-protocol.ts:263` | +| Client to Host | `{ type: 'open', streamId, endpoint, payload }` | `stream-protocol.ts` | +| Client to Host | `{ type: 'cancel', streamId }` | `stream-protocol.ts` | +| Host to Client | `{ type: 'item', streamId, value? }` | `stream-protocol.ts` | +| Host to Client | `{ type: 'error', streamId, error: { code, message, details } }` | `stream-protocol.ts` | +| Host to Client | `{ type: 'end', streamId }` | `stream-protocol.ts` | -The Client can send only open and cancel. The parser `parseRemoteStreamClientMessage` (`stream-protocol.ts:270-284`) throws on any other frame, and a Host connection that receives one closes the whole socket with 1008 (`stream-server.ts:121-125`). In short, the Gateway today supports only "a backend iterable sent to the frontend". Every feature that needs the frontend to send data to the backend continuously takes its own detour. +The Client can send only open and cancel. The parser `parseRemoteStreamClientMessage` (`stream-protocol.ts`) throws on any other frame, and a Host connection that receives one closes the whole socket with 1008 (`stream-server.ts`). In short, the Gateway today supports only "a backend iterable sent to the frontend". Every feature that needs the frontend to send data to the backend continuously takes its own detour. -A Host method also has no notion of who initiated this call: `InvokeRemoteRequest = { namespace, method, args, signal }` (`packages/api/gateway/src/types.ts:10-19`) has no caller slot, `RemoteStreamOpener` has the signature `(endpoint, payload, signal)` (`stream-server.ts:13-17`), and the WebSocket checks the cookie once, at the handshake. +A Host method also has no notion of who initiated this call: `InvokeRemoteRequest = { namespace, method, args, signal }` (`packages/api/gateway/src/types.ts`) has no caller slot, `RemoteStreamOpener` has the signature `(endpoint, payload, signal)` (`stream-server.ts`), and the WebSocket checks the cookie once, at the handshake. ### Three unrelated uplink mechanisms | Feature | Uplink path | Location | Cost | | --- | --- | --- | --- | -| Web terminal keystrokes | One unary RPC `terminal.write(agent, id, attachmentId, data)` per xterm `onData`; the Client serializes them through a promise chain and caps its own byte budget | `packages/api/terminal-controller/src/client/model.ts:193-205`; Host `src/index.ts:229-233` | One HTTP round trip and one authentication per keystroke; two carriers correlated by hand through `attachmentId`; `inputFull` when the budget is exhausted | -| File upload | A `Blob` or `ReadableStream` goes to the native `POST /api/session/uploadFileBinary` outside the Remote layer, with `duplex: 'half'` | `packages/client/file-upload/src/client/runtime.ts:198-211`; route `src/index.ts:72-80` | Bypasses Typert descriptors; no type projection | -| Answers to approvals and questions | The Host sends waterfall frames down the `$events` stream, the Client answers through a separate unary RPC `$events/result`, correlated by `clientId` plus `eventId` | `packages/api/gateway/src/client/remote-events.ts:219-224`; Host `index.ts:357-369` | A second correlation key; `index.ts:527-529` records the race this creates | +| Web terminal keystrokes | One unary RPC `terminal.write(agent, id, attachmentId, data)` per xterm `onData`; the Client serializes them through a promise chain and caps its own byte budget | `packages/api/terminal-controller/src/client/model.ts`; Host `src/index.ts` | One HTTP round trip and one authentication per keystroke; two carriers correlated by hand through `attachmentId`; `inputFull` when the budget is exhausted | +| File upload | A `Blob` or `ReadableStream` goes to the native `POST /api/session/uploadFileBinary` outside the Remote layer, with `duplex: 'half'` | `packages/client/file-upload/src/client/runtime.ts`; route `src/index.ts` | Bypasses Typert descriptors; no type projection | +| Answers to approvals and questions | The Host sends waterfall frames down the `$events` stream, the Client answers through a separate unary RPC `$events/result`, correlated by `clientId` plus `eventId` | `packages/api/gateway/src/client/remote-events.ts`; Host `index.ts` | A second correlation key; `index.ts` records the race this creates | These three mechanisms solve the same problem: the Client wants to write into a logical stream that is already open. @@ -41,7 +41,7 @@ These three mechanisms solve the same problem: the Client wants to write into a - **Separate authentication and lookup per uplink.** Every `write(agent, id, attachmentId, data)` resolves `agent`, looks up the terminal, and validates the attachment. - **No EOF.** The Client cannot express "I am done writing"; a half-close needs yet another RPC. - **Cancellation does not cover both halves.** Cancelling the downlink stream does not cancel queued uplink writes, and vice versa. -- **Backpressure implemented three times.** The downlink pulls the iterator at the pace of the socket write callback (`stream-server.ts:155-199`); the terminal uses a bounded follower that makes a slow consumer fail explicitly (`terminal-controller/src/stream.ts:21-32`); the uplink relies on the Client capping `maxInputBytes` itself. +- **Backpressure implemented three times.** The downlink pulls the iterator at the pace of the socket write callback (`stream-server.ts`); the terminal uses a bounded follower that makes a slow consumer fail explicitly (`terminal-controller/src/stream.ts`); the uplink relies on the Client capping `maxInputBytes` itself. - **The documentation is already stale.** `docs/api-gateway.md:160` still says Remote handles one request and one result, a sentence that lags the stream mode the README records. ### Relation to background jobs @@ -60,7 +60,7 @@ The process-shaped duplex stream face of background jobs (archived as a draft PR ### In one sentence -Every Remote stream accepts a Client uplink. The method's return type `RemoteStream` declares the downlink item type and the uplink item type together; the generator derives two codecs from it. A Host method takes the uplink iterator through `this.ctx.invocation.uplink()` and learns the caller through `this.ctx.invocation.peer`. The generated Client method returns a `RemoteStream` handle: `for await` reads the downlink, `send` / `end` write the uplink, `dispose` closes. Reopening after disconnection, cursor resumption, and baseline validation all belong to upper-layer protocols. +Every Remote stream accepts a Client uplink. The method's return type `RemoteStream` declares the downlink item type and the uplink item type together; the generator derives two codecs from it. A Host method takes the uplink iterator through `this.ctx.invocation.uplink()` and learns the caller through `this.ctx.invocation.peer`. The generated Client method returns a `RemoteStreamHandle`: `for await` reads the downlink, `send` / `end` write the uplink, `dispose` closes. Reopening after disconnection, cursor resumption, and baseline validation all belong to upper-layer protocols. ``` Client Host @@ -73,7 +73,7 @@ Every Remote stream accepts a Client uplink. The method's return type `RemoteStr │ s.end() → end { streamId } ───────▶ inbox.end() │ method(...args, signal) │ ◀─────── item { streamId, o1 } │ for await (v of this.ctx.invocation.uplink()) … │ for await (o of s) … ◀─────── item { streamId, o2 } │ yield o … - │ s.dispose() → cancel { streamId } ───────▶ control.abort() │ signal 中止;uplink.return() + │ s.dispose() → cancel { streamId } ───────▶ control.abort() │ signal aborts; uplink.return() │ ◀─────── end | error ``` @@ -96,9 +96,9 @@ Every Remote stream accepts a Client uplink. The method's return type `RemoteStr ```text // @deepseek-ai/dsh-typert-protocol /** - * 一条 Remote 流。Host 面:方法返回它,运行时就是 AsyncIterable。 - * Client 面上生成方法返回的是 RemoteStreamHandle;两个名字各自只有一个含义。 - * In 是客户端可上行的项类型;缺省 never 表示该方法不读上行。 + * One Remote stream. Host face: the method returns it, and at runtime it is AsyncIterable. + * On the Client face the generated method returns RemoteStreamHandle; each name has exactly one meaning. + * In is the item type the Client may send uplink; the default never means the method reads no uplink. */ export type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } @@ -110,13 +110,13 @@ async *attach(request: JobAttachRequest, signal: AbortSignal): RemoteStream +// Generated Client signature: same parameters, returns the handle +attach(request: JobAttachRequest, signal?: AbortSignal): RemoteStreamHandle -// Client 使用 +// Client usage const stream = remote.job.attach(req, signal) for await (const frame of stream) … -stream.send(frame) // 类型为 JobInputFrame +stream.send(frame) // typed as JobInputFrame stream.end() stream.dispose() ``` @@ -126,31 +126,33 @@ stream.dispose() ### Host face ```text -/** 本次 Remote 调用的上下文。方法通过 this.ctx.invocation 读它。 */ +/** The context of this Remote call. A method reads it through this.ctx.invocation. */ export interface RemoteInvocation { readonly request: { readonly namespace: string readonly method: string readonly args: Readonly> } - /** 接收服务的 Cordis service key。 */ + /** Cordis service key of the receiving service. */ readonly service: string - /** 发起调用的 Peer。进程内载体与未接纳的调用是操作者。 */ + /** The Peer that initiated the call. In-process carriers and unadmitted calls are the operator. */ readonly peer: PeerScope - /** 载体取消:客户端 cancel、socket 关闭、上行失败。 */ + /** Carrier cancellation: Client cancel, socket closure, uplink failure. */ readonly signal: AbortSignal /** - * 本次调用的上行项。只能取一次,第二次抛错。描述符带 uplink codec 时 - * 逐项解码为 In;不带时交付 unknown,只做 JSON 安全校验。 - * 客户端 end 后迭代结束;方法结束下行时网关调用它的 return(),未消费的项丢弃。 - * 泛型 In 只是调用方的类型断言,运行时按描述符解码,不做交叉校验。 + * The uplink items of this call. Can be taken once; the second call throws. With an uplink codec + * in the descriptor each item is decoded to In; without one, items are delivered as unknown with + * only a JSON-safety check. + * The iteration ends after the Client's end; when the method ends the downlink, the Gateway calls + * its return() and drops unconsumed items. + * The generic In is only the caller's type assertion; runtime decodes per the descriptor and does not cross-check. */ uplink(): AsyncIterable } declare module '@deepseek-ai/cordis' { interface Context { - /** 本 Context 为之派生的 Remote 调用;非 Remote 调用派生的 Context 上为 undefined。 */ + /** The Remote call this Context was derived for; undefined on a Context not derived from a Remote call. */ readonly invocation: RemoteInvocation | undefined } } @@ -165,7 +167,7 @@ Rules: #### How the invocation context reaches the method -The mechanism is carried over from the `remote-client-access` branch, and the Cordis source confirms that it holds: +The mechanism relies on two existing Cordis behaviors: - `ctx.extend(meta)` is `Object.create(parent)` plus own properties (`vendor/cordis/src/context.ts:99-107`): no registration, no fiber, nothing to release, so deriving one per call costs nothing. - `ctx.get(service)` returns the `createTraceable(ctx, service)` proxy (`vendor/cordis/src/reflect.ts:233-234`, `utils.ts:117-125`). Reading the `ctx` property returns the accessing Context directly (`utils.ts:175`); reading a method wraps it in `createShadowMethod`, whose `apply` replaces `this` with a shadow receiver whose `ctx` is `ctx.extend({ [symbols.shadow]: origin })` (`utils.ts:149-163`). @@ -174,10 +176,10 @@ The mechanism is carried over from the `remote-client-access` branch, and the Co ### PeerScope ```text -/** 一个 Peer 的不透明身份。 */ +/** The opaque identity of one Peer. */ export type PeerId = Branded<'PeerId'> -/** 一个 Peer 在本 Host 上的会话:接纳它的一方打开与释放;ctx 拥有连接期注册。 */ +/** One Peer's session on this Host: opened and released by the admitting side; ctx owns connection-lifetime registrations. */ export interface PeerScope { readonly id: PeerId readonly ctx: Context @@ -194,18 +196,18 @@ This Host has exactly one Peer: the operator. `@deepseek-ai/dsh-client-connectio The Gateway's `handleUpgrade(req, socket, head, peer)` binds an opener with a fixed Peer to that socket and registers socket closure in `peer.ctx.effect`; when the scope is already inactive, it closes the socket directly with 1001 instead. When `InvokeRemoteRequest.peer?` or the `peer` parameter of `RemoteStreamOpener` is absent, the answer is the operator, so existing in-process callers change nothing; in a composition without a Connection, the operator stand-in the Gateway builds for itself is likewise created through `createScope`, with the same `dispose()` contract. Looking up a Peer by id, associating a carrier with a second Peer, and Peer open and close events all wait until the first consumer that needs multiple Peers appears. -Not adopted: `@Access`, `AccessDeclaration`, `AccessLevel`, `AccessTarget`, `vouch` / `vouched`, the `remote/invoke` and `remote/deliver` events, `mayDeliver`, `requireVoucher`, `gateway/access-denied`. Who the Peer is and what it may do are attached by business plugins on their own through `peer.ctx` and are outside this Note. +The Peer carries no access model: who the Peer is and what it may do are attached by business plugins on their own through `peer.ctx`. ### Client face ```text -/** 生成方法在 Client 面返回的句柄。与 Host 的 RemoteStream 是两个名字,从同一个入口导出。 */ +/** The handle a generated method returns on the Client face. A different name from the Host's RemoteStream, exported from the same entry point. */ export interface RemoteStreamHandle extends AsyncIterable { - /** 发送一个上行项。非无损 JSON 值、流已终止或已 end 时抛错。 */ + /** Send one uplink item. Throws on a value that is not lossless JSON, after termination, or after end. */ send(item: In): void - /** 上行半关闭:发 end 帧。幂等。 */ + /** Half-close the uplink: sends the end frame. Idempotent. */ end(): void - /** 取消整条逻辑流:发 cancel 帧(未收到终止帧时),下行迭代器安静结束。 */ + /** Cancel the whole logical stream: sends the cancel frame (when no terminal frame has arrived); the downlink iterator ends quietly. */ dispose(): void } ``` @@ -221,11 +223,11 @@ export interface RemoteStreamHandle extends AsyncIterable { ```text export type RemoteStreamClientMessage = | { readonly type: 'open'; readonly streamId: string; readonly endpoint: string; readonly payload: unknown } - | { readonly type: 'item'; readonly streamId: string; readonly value?: unknown } // 新增:上行项 - | { readonly type: 'end'; readonly streamId: string } // 新增:上行半关闭 + | { readonly type: 'item'; readonly streamId: string; readonly value?: unknown } // new: uplink item + | { readonly type: 'end'; readonly streamId: string } // new: uplink half-close | { readonly type: 'cancel'; readonly streamId: string } -/** Host 发出的帧不变。 */ +/** Frames sent by the Host are unchanged. */ export type RemoteStreamServerMessage = | { readonly type: 'item'; readonly streamId: string; readonly value?: unknown } | { readonly type: 'error'; readonly streamId: string; readonly error: RemoteStreamFailure } @@ -239,15 +241,15 @@ Parsing rules: `item` has exactly the keys `type` and `streamId` plus an optiona #### Host side ``` - open 帧 - (无) ─────────────────────▶ opening ──── opener 解析完成 ────▶ running + open frame + (none) ───────────────────▶ opening ──── opener resolves ────▶ running │ │ - │ item 帧: inbox.push │ item 帧: inbox.push - │ end 帧: inbox.end │ end 帧: inbox.end(上行半关闭) - │ │ 方法 yield: 发 item 帧 - │ cancel 帧 / socket 关闭 ────────▶│ cancel 帧 / socket 关闭: control.abort + │ item frame: inbox.push │ item frame: inbox.push + │ end frame: inbox.end │ end frame: inbox.end (uplink half-close) + │ │ method yield: sends item frame + │ cancel frame / socket close ────▶│ cancel frame / socket close: control.abort ▼ ▼ - aborted ◀───────────────────────── finished(发 end 或 error 帧) + aborted ◀───────────────────────── finished (sends end or error frame) ``` `item` frames that arrive during `opening` enter the inbox: `receive()` is synchronous and creates the `ActiveStream` and its inbox while handling the `open` frame, whereas the opener is asynchronous. Items the Client sends immediately after `open` are not lost. @@ -255,11 +257,11 @@ Parsing rules: `item` has exactly the keys `type` and `streamId` plus an optiona #### Client side ``` - 调用生成方法 ──▶ 等 socket ──▶ 发 open ──▶ 句柄可用 - │ send(v): 发 item;end(): 发 end - │ 下行:inbox.next() → yield;end → 结束;error → 抛 - │ 下行先终止:send/end 抛错 - │ dispose 或调用方 signal 中止:发 cancel(未收到终止帧时) + call generated method ──▶ await socket ──▶ send open ──▶ handle usable + │ send(v): sends item; end(): sends end + │ downlink: inbox.next() → yield; end → ends; error → throws + │ downlink terminates first: send/end throw + │ dispose or caller signal abort: sends cancel (when no terminal frame has arrived) ``` ### Half-close and termination @@ -269,7 +271,7 @@ Parsing rules: `item` has exactly the keys `type` and `streamId` plus an optiona | Client `end` | Still open | The Host's `uplink()` iteration ends; the method keeps producing. This is the stdin EOF form | | Still open | The Host method finishes and sends `end` | The Client handle terminates, and later `send` / `end` throw; the Host calls `return()` on the `uplink` iterator and drops unconsumed items | | Still open | The Host method throws and sends `error` | Same as above; the Client downlink fails with `RemoteError` | -| Client `dispose` or signal abort | Any | Sends `cancel`; the Host runs `control.abort()`, `cancellableStream` calls `return()` on the method iterator and then on `uplink`; no terminal frame is sent | +| Client `dispose` or signal abort | Any | Sends `cancel`; the Host runs `control.abort()`, `cancellableStream` closes `uplink` first and then calls `return()` on the method iterator; no terminal frame is sent | | Socket closes | Any | The Host aborts every stream and awaits `done`; on the Client every stream fails with `RemoteStreamCarrierError` | | `item` received after `end` | Any | The stream fails with a `gateway/protocol` error frame and aborts; the socket stays open | | `item` / `end` with an unknown `streamId` | Any | Ignored, like `cancel`: after the Host ends a stream and deletes its id, uplink frames still in flight from the Client are normal and must not take down the other streams on the same socket | @@ -318,13 +320,13 @@ An in-process carrier with no `peer` means the operator. export type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } export type PeerId = Branded<'PeerId'> export interface PeerScope { readonly id: PeerId; readonly ctx: Context; dispose(): Promise } -export interface RemoteInvocation { … } // 见 Host 面 +export interface RemoteInvocation { … } // see Host face declare module '@deepseek-ai/cordis' { interface Context { readonly invocation: RemoteInvocation | undefined } } export interface InvocationDescriptor { - // 既有字段不变;mode 仍只有 'stream' + // existing fields unchanged; mode still has only 'stream' readonly mode?: 'stream' - /** 上行项 codec,从返回类型 RemoteStream 的 In 生成;In 为 never 时缺席。 */ + /** Uplink item codec, generated from In of the return type RemoteStream; absent when In is never. */ readonly uplink?: { readonly codec: TypertCodec } readonly cancellation?: { readonly parameter: 'signal' } readonly result: TypertCodec @@ -336,7 +338,7 @@ The `Remote` decorator, `RemoteMethodOptions`, and `RemoteMethodMarker` recogniz ### typert generator (`packages/typert/generator/src`) - `model.ts`: `InvocationModel.uplink?: { boundary: RemoteBoundaryModel }`. -- `analyzer.ts` `remoteResultType` (currently `~1402`): for `mode: 'stream'`, the accepted return-type wrappers are `Iterable`, `AsyncIterable`, and `RemoteStream`. `RemoteStream` is recognized the same way as the standard library's `AsyncIterable`: by symbol name plus declaring file (`types.ts` of `@deepseek-ai/dsh-typert-protocol`). The first type argument is the downlink item; when the second is present and is not `never`, an `uplink` boundary is generated under the key `${endpoint}:uplink`. +- `analyzer.ts` `remoteResultType`: for `mode: 'stream'`, the accepted return-type wrappers are `Iterable`, `AsyncIterable`, and `RemoteStream`. `RemoteStream` is recognized the same way as the standard library's `AsyncIterable`: by symbol name plus declaring file (`types.ts` of `@deepseek-ai/dsh-typert-protocol`). The first type argument is the downlink item; when the second is present and is not `never`, an `uplink` boundary is generated under the key `${endpoint}:uplink`. - `emitter.ts`: the descriptor literal emits `uplink: { codec }`; the generated Client signature returns `RemoteStreamHandle`. - The parameter loop recognizes no parameter named `uplink`. @@ -352,9 +354,9 @@ export interface InvokeRemoteRequest { readonly namespace: string readonly method: string readonly args: Readonly> - /** 上行项;缺席等价于立即结束的迭代器。 */ + /** Uplink items; absent is equivalent to an iterator that ends immediately. */ readonly uplink?: AsyncIterable - /** 发起调用的 Peer;缺席即操作者。 */ + /** The Peer that initiated the call; absent means the operator. */ readonly peer?: PeerScope readonly signal?: AbortSignal } @@ -373,8 +375,8 @@ export type RemoteStreamOpener = ( control: AbortController, ) => Promise> -// index.ts Config 新增 -/** 每条逻辑流的上行 inbox 上限,按帧的 UTF-8 字节计(默认 262144)。 */ +// index.ts Config addition +/** Uplink inbox limit per logical stream, in UTF-8 bytes of frames (default 262144). */ readonly streamInboxBytes?: number ``` @@ -401,23 +403,23 @@ if (descriptor.cancellation !== undefined) args.push(signal) ```text interface ActiveStream { readonly control: AbortController; readonly inbox: UplinkInbox; done: Promise } -/** 有界上行队列;作为 uplink() 的源被迭代。 */ +/** Bounded uplink queue; iterated as the source of uplink(). */ class UplinkInbox implements AsyncIterable, AsyncIterator { constructor(maxBytes: number, onOverflow: (error: RemoteError) => void) - push(value: unknown, frameBytes: number): void // ended 后 push 使流以 gateway/protocol 失败;超限调用 onOverflow - end(): void // 幂等 - fail(error: unknown): void // 取消或载体关闭时让迭代器以错误结束 - next() / return() // 手写:next 与失败竞速,return 立即释放 + push(value: unknown, frameBytes: number): void // push after ended fails the stream with gateway/protocol; overflow calls onOverflow + end(): void // idempotent + fail(error: unknown): void // ends the iterator with an error on cancel or carrier closure + next() / return() // hand-written: next races failure, return releases immediately } ``` `receive(text)` dispatch: ``` -open : 已存在 → 抛错(socket 1008);否则创建 { control, inbox, done },启动 pump -item : 不存在 → 忽略;否则 inbox.push(value, bytes) -end : 不存在 → 忽略;否则 inbox.end() -cancel : 不存在 → 忽略;否则 control.abort(new Error('Remote stream cancelled')) +open : exists → throw (socket 1008); otherwise create { control, inbox, done } and start the pump +item : missing → ignore; otherwise inbox.push(value, bytes) +end : missing → ignore; otherwise inbox.end() +cancel : missing → ignore; otherwise control.abort(new Error('Remote stream cancelled')) ``` `pump` passes the inbox and the socket's Peer to the opener and calls `inbox.fail(...)` in its `finally`. An abort whose reason is a `RemoteError` is sent by the pump as an `error` frame (a failure raised by the Gateway or the mux); an abort whose reason is a plain `Error` (cancel, socket closure) sends no terminal frame. @@ -447,40 +449,40 @@ cancel : 不存在 → 忽略;否则 control.abort(new Error('Remote stream ca ### Open, uplink, half-close, downlink end ``` -Client 调用方 mux client mux server gateway Host 方法 - │ attach(req) │ │ │ │ - │───────────────────▶│ open { id, ep, payload } ─────────────────▶│ 创建 inbox │ - │ s.send(v1) │ item { id, v1 } ──────────────────────────▶│ inbox.push(v1) │ - │ │ │ │ prepareInvocation │ - │ │ │ │ extend({ invocation }) │ - │ │ │ │───── method(req, signal) ─────────▶│ - │ │ │ │ │ uplink() → v1 - │ s.send(v2) │ item { id, v2 } ──────────────────────────▶│ inbox.push(v2) │ → v2 - │ s.end() │ end { id } ───────────────────────────────▶│ inbox.end() │ 迭代结束 - │◀── yield o1 ───────│◀────────────────────── item { id, o1 } ────│◀── yield o1 ────────│ - │◀── 结束 ───────────│◀────────────────────── end { id } ─────────│◀── 方法返回 ─────────│ gateway 调 uplink.return() +Client caller mux client mux server gateway Host method + │ attach(req) │ │ │ │ + │───────────────────▶│ open { id, ep, payload } ──────────────────▶│ create inbox │ + │ s.send(v1) │ item { id, v1 } ───────────────────────────▶│ inbox.push(v1) │ + │ │ │ │ prepareInvocation │ + │ │ │ │ extend({ invocation }) │ + │ │ │ │── method(req, signal) ─▶│ + │ │ │ │ │ uplink() → v1 + │ s.send(v2) │ item { id, v2 } ───────────────────────────▶│ inbox.push(v2) │ → v2 + │ s.end() │ end { id } ────────────────────────────────▶│ inbox.end() │ iteration ends + │◀── yield o1 ───────│◀────────────────────── item { id, o1 } ─────│◀── yield o1 ────────────│ + │◀── ends ───────────│◀────────────────────── end { id } ──────────│◀── method returns ──────│ gateway calls uplink.return() ``` ### Client dispose ``` -Client 调用方 mux client mux server gateway Host 方法 - │ s.dispose() │ │ │ │ - │───────────────────▶│ 停泵;源 return()(不等待) │ │ - │◀── 迭代器结束 ─────│ cancel { id } ────────────────────────────▶│ control.abort() │ - │ │ │ │ cancellableStream: iterator.return() ──▶│ finally 清理 - │ │ │ │ uplink.return() │ - │ │ │ 不发终止帧 │ │ +Client caller mux client mux server gateway Host method + │ s.dispose() │ │ │ │ + │───────────────────▶│ stop pump; source return() (not awaited) │ │ + │◀── iterator ends ──│ cancel { id } ─────────────────────────────▶│ control.abort() │ + │ │ │ │ cancellableStream: invocation.close() closes uplink + │ │ │ │ iterator.return() ─────▶│ finally cleanup + │ │ │ no terminal frame │ │ ``` ### inbox overflow ``` -Client 调用方 mux client mux server gateway / Host 方法 +Client caller mux client mux server gateway / Host method │ s.send(vN) │ item { id, vN } ─────▶│ inbox.push: bytes > streamInboxBytes │ │ │ │ onOverflow → control.abort(RemoteError uplink-overflow) - │ │ │ │ uplink() 以该错误结束;方法迭代器 return() - │◀── throw ──────────│◀── error { id, uplink-overflow } ─────────│ pump 因 reason 是 RemoteError 发 error 帧 + │ │ │ │ uplink() ends with that error; method iterator return() + │◀── throw ──────────│◀──────────────────────│ error { id, uplink-overflow }: the pump sends an error frame because the reason is a RemoteError ``` ## Boundary matrix @@ -527,7 +529,7 @@ class EchoService extends TypertRemoteService { } } -// 客户端 +// Client const stream = remote.echo.echo('> ') stream.send('a'); stream.send('b'); stream.end() const replies: string[] = [] @@ -560,7 +562,7 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b'] ## Consequences -- **Bought**: one stream with two directions, so interactive scenarios such as terminal keystrokes, approval answers, and background-job stdin no longer each build their own "one stream plus one unary"; a Host method knows its caller; the uplink and downlink types are declared in one place, the return type, and uplink items are validated per item as strictly as downlink items; the generated output and wire frames of existing `AsyncIterable` methods and unary methods are completely unchanged. +- **Bought**: one stream with two directions, so interactive scenarios such as terminal keystrokes, approval answers, and background-job stdin no longer each build their own "one stream plus one unary"; a Host method knows its caller; the uplink and downlink types are declared in one place, the return type; uplink items come from the browser and are validated strictly per item at the Host, while downlink items are Host-produced typed values that pass straight through; the generated output and wire frames of existing `AsyncIterable` methods and unary methods are completely unchanged. - **`this.ctx.invocation` is `undefined` outside a Remote call**, so a method that reads it must handle the optional; a service method invoked only directly in process correctly reads `undefined`. - **The inbox limit applies to every stream**: a method that does not read its uplink fails as a whole when it receives many uplink frames. This is a deliberate explicit failure, recorded in the README. - **The operator is the only Peer**, and `admit` is the only admission point; the first consumer that needs a second Peer must add opening, association, and events in `dsh-client-connection`. diff --git a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md index 5fee4d934f..d3982aa433 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md @@ -8,29 +8,29 @@ Status: implemented ### 现状 -`dsh-api-gateway` 在一条 WebSocket 上复用全部 Typert Remote 流,路径固定为 `/api/remote.mux`(`packages/api/gateway/src/stream-protocol.ts:6`)。一个 Host 方法用 `@Remote({ mode: 'stream' })` 修饰并返回 `Iterable` 或 `AsyncIterable`,就成为一条 Host 到 Client 的逻辑流;可选的末位 `signal: AbortSignal` 是唯一的保留参数,不进线路参数,由网关在解码后的业务参数之后追加(`packages/api/gateway/src/index.ts:613`)。客户端拿到的生成方法返回一个裸 `AsyncIterable`。 +`dsh-api-gateway` 在一条 WebSocket 上复用全部 Typert Remote 流,路径固定为 `/api/remote.mux`(`packages/api/gateway/src/stream-protocol.ts`)。一个 Host 方法用 `@Remote({ mode: 'stream' })` 修饰并返回 `Iterable` 或 `AsyncIterable`,就成为一条 Host 到 Client 的逻辑流;可选的末位 `signal: AbortSignal` 是唯一的保留参数,不进线路参数,由网关在解码后的业务参数之后追加(`packages/api/gateway/src/index.ts`)。客户端拿到的生成方法返回一个裸 `AsyncIterable`。 线路帧只有五种: | 方向 | 帧 | 定义 | | --- | --- | --- | -| Client 到 Host | `{ type: 'open', streamId, endpoint, payload }` | `stream-protocol.ts:243-249` | -| Client 到 Host | `{ type: 'cancel', streamId }` | `stream-protocol.ts:250` | -| Host 到 Client | `{ type: 'item', streamId, value? }` | `stream-protocol.ts:261` | -| Host 到 Client | `{ type: 'error', streamId, error: { code, message, details } }` | `stream-protocol.ts:262` | -| Host 到 Client | `{ type: 'end', streamId }` | `stream-protocol.ts:263` | +| Client 到 Host | `{ type: 'open', streamId, endpoint, payload }` | `stream-protocol.ts` | +| Client 到 Host | `{ type: 'cancel', streamId }` | `stream-protocol.ts` | +| Host 到 Client | `{ type: 'item', streamId, value? }` | `stream-protocol.ts` | +| Host 到 Client | `{ type: 'error', streamId, error: { code, message, details } }` | `stream-protocol.ts` | +| Host 到 Client | `{ type: 'end', streamId }` | `stream-protocol.ts` | -客户端能发的只有打开与取消。解析器 `parseRemoteStreamClientMessage`(`stream-protocol.ts:270-284`)对其他任何帧抛错,Host 连接收到后以 1008 关闭整个 socket(`stream-server.ts:121-125`)。结论:网关今天只支持"后端 iterable 发到前端"。需要前端向后端持续发数据的功能,各自绕路。 +客户端能发的只有打开与取消。解析器 `parseRemoteStreamClientMessage`(`stream-protocol.ts`)对其他任何帧抛错,Host 连接收到后以 1008 关闭整个 socket(`stream-server.ts`)。结论:网关今天只支持"后端 iterable 发到前端"。需要前端向后端持续发数据的功能,各自绕路。 -Host 方法也没有"这次调用是谁发起的"这个概念:`InvokeRemoteRequest = { namespace, method, args, signal }`(`packages/api/gateway/src/types.ts:10-19`)没有调用者槽位,`RemoteStreamOpener` 的签名是 `(endpoint, payload, signal)`(`stream-server.ts:13-17`),WebSocket 只在握手时校一次 cookie。 +Host 方法也没有"这次调用是谁发起的"这个概念:`InvokeRemoteRequest = { namespace, method, args, signal }`(`packages/api/gateway/src/types.ts`)没有调用者槽位,`RemoteStreamOpener` 的签名是 `(endpoint, payload, signal)`(`stream-server.ts`),WebSocket 只在握手时校一次 cookie。 ### 三套各自为政的上行机制 | 功能 | 上行方式 | 位置 | 代价 | | --- | --- | --- | --- | -| Web 终端按键 | 每次 xterm `onData` 一个 unary RPC `terminal.write(agent, id, attachmentId, data)`,客户端用 promise 链串行化并自限字节预算 | `packages/api/terminal-controller/src/client/model.ts:193-205`;Host `src/index.ts:229-233` | 一次按键一次 HTTP 往返与鉴权;两条载体靠 `attachmentId` 手工关联;满了报 `inputFull` | -| 文件上传 | `Blob` 或 `ReadableStream` 走 Remote 层之外的原生 `POST /api/session/uploadFileBinary`,`duplex: 'half'` | `packages/client/file-upload/src/client/runtime.ts:198-211`;路由 `src/index.ts:72-80` | 不经 Typert 描述符,没有类型投影 | -| 审批与提问的回答 | Host 在 `$events` 流上下发 waterfall 帧,Client 用一个独立的 unary RPC `$events/result` 回答,靠 `clientId` 加 `eventId` 关联 | `packages/api/gateway/src/client/remote-events.ts:219-224`;Host `index.ts:357-369` | 第二套关联键;`index.ts:527-529` 记录了由此产生的竞态 | +| Web 终端按键 | 每次 xterm `onData` 一个 unary RPC `terminal.write(agent, id, attachmentId, data)`,客户端用 promise 链串行化并自限字节预算 | `packages/api/terminal-controller/src/client/model.ts`;Host `src/index.ts` | 一次按键一次 HTTP 往返与鉴权;两条载体靠 `attachmentId` 手工关联;满了报 `inputFull` | +| 文件上传 | `Blob` 或 `ReadableStream` 走 Remote 层之外的原生 `POST /api/session/uploadFileBinary`,`duplex: 'half'` | `packages/client/file-upload/src/client/runtime.ts`;路由 `src/index.ts` | 不经 Typert 描述符,没有类型投影 | +| 审批与提问的回答 | Host 在 `$events` 流上下发 waterfall 帧,Client 用一个独立的 unary RPC `$events/result` 回答,靠 `clientId` 加 `eventId` 关联 | `packages/api/gateway/src/client/remote-events.ts`;Host `index.ts` | 第二套关联键;`index.ts` 记录了由此产生的竞态 | 这三套机制解决的是同一个问题:一条已经打开的逻辑流,客户端想往里写。 @@ -41,7 +41,7 @@ Host 方法也没有"这次调用是谁发起的"这个概念:`InvokeRemoteReq - **每次上行单独鉴权与查找**。`write(agent, id, attachmentId, data)` 每次解析 `agent`、查终端、校验 attachment。 - **没有 EOF**。客户端无法表达"我这边写完了",半关闭要再加一个 RPC。 - **取消不覆盖两半**。取消下行流不会取消排队中的上行写;反之亦然。 -- **背压各写一套**。下行按 socket 写回调节拍拉迭代器(`stream-server.ts:155-199`);终端用有界 follower 让慢消费者显式失败(`terminal-controller/src/stream.ts:21-32`);上行靠客户端自限 `maxInputBytes`。 +- **背压各写一套**。下行按 socket 写回调节拍拉迭代器(`stream-server.ts`);终端用有界 follower 让慢消费者显式失败(`terminal-controller/src/stream.ts`);上行靠客户端自限 `maxInputBytes`。 - **文档已经过时**。`docs/api-gateway.md:160` 仍写着 Remote 只处理一请求一结果,这一句已经落后于 README 记录的 stream 模式。 ### 与后台 job 的关系 @@ -60,7 +60,7 @@ Host 方法也没有"这次调用是谁发起的"这个概念:`InvokeRemoteReq ### 一句话 -所有 Remote 流都允许客户端上行。方法的返回类型 `RemoteStream` 同时声明下行项与上行项的类型;生成器从中生成两个 codec。Host 方法通过 `this.ctx.invocation.uplink()` 取上行迭代器,通过 `this.ctx.invocation.peer` 知道调用者。客户端生成方法返回 `RemoteStream` 句柄:`for await` 读下行,`send` / `end` 写上行,`dispose` 关。断线重开、游标续传、基线校验都是上层协议的事。 +所有 Remote 流都允许客户端上行。方法的返回类型 `RemoteStream` 同时声明下行项与上行项的类型;生成器从中生成两个 codec。Host 方法通过 `this.ctx.invocation.uplink()` 取上行迭代器,通过 `this.ctx.invocation.peer` 知道调用者。客户端生成方法返回 `RemoteStreamHandle`:`for await` 读下行,`send` / `end` 写上行,`dispose` 关。断线重开、游标续传、基线校验都是上层协议的事。 ``` Client Host @@ -73,7 +73,7 @@ Host 方法也没有"这次调用是谁发起的"这个概念:`InvokeRemoteReq │ s.end() → end { streamId } ───────▶ inbox.end() │ method(...args, signal) │ ◀─────── item { streamId, o1 } │ for await (v of this.ctx.invocation.uplink()) … │ for await (o of s) … ◀─────── item { streamId, o2 } │ yield o … - │ s.dispose() → cancel { streamId } ───────▶ control.abort() │ signal 中止;uplink.return() + │ s.dispose() → cancel { streamId } ───────▶ control.abort() │ signal aborts; uplink.return() │ ◀─────── end | error ``` @@ -96,9 +96,9 @@ Host 方法也没有"这次调用是谁发起的"这个概念:`InvokeRemoteReq ```text // @deepseek-ai/dsh-typert-protocol /** - * 一条 Remote 流。Host 面:方法返回它,运行时就是 AsyncIterable。 - * Client 面上生成方法返回的是 RemoteStreamHandle;两个名字各自只有一个含义。 - * In 是客户端可上行的项类型;缺省 never 表示该方法不读上行。 + * One Remote stream. Host face: the method returns it, and at runtime it is AsyncIterable. + * On the Client face the generated method returns RemoteStreamHandle; each name has exactly one meaning. + * In is the item type the Client may send uplink; the default never means the method reads no uplink. */ export type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } @@ -110,13 +110,13 @@ async *attach(request: JobAttachRequest, signal: AbortSignal): RemoteStream +// Generated Client signature: same parameters, returns the handle +attach(request: JobAttachRequest, signal?: AbortSignal): RemoteStreamHandle -// Client 使用 +// Client usage const stream = remote.job.attach(req, signal) for await (const frame of stream) … -stream.send(frame) // 类型为 JobInputFrame +stream.send(frame) // typed as JobInputFrame stream.end() stream.dispose() ``` @@ -126,31 +126,33 @@ stream.dispose() ### Host 面 ```text -/** 本次 Remote 调用的上下文。方法通过 this.ctx.invocation 读它。 */ +/** The context of this Remote call. A method reads it through this.ctx.invocation. */ export interface RemoteInvocation { readonly request: { readonly namespace: string readonly method: string readonly args: Readonly> } - /** 接收服务的 Cordis service key。 */ + /** Cordis service key of the receiving service. */ readonly service: string - /** 发起调用的 Peer。进程内载体与未接纳的调用是操作者。 */ + /** The Peer that initiated the call. In-process carriers and unadmitted calls are the operator. */ readonly peer: PeerScope - /** 载体取消:客户端 cancel、socket 关闭、上行失败。 */ + /** Carrier cancellation: Client cancel, socket closure, uplink failure. */ readonly signal: AbortSignal /** - * 本次调用的上行项。只能取一次,第二次抛错。描述符带 uplink codec 时 - * 逐项解码为 In;不带时交付 unknown,只做 JSON 安全校验。 - * 客户端 end 后迭代结束;方法结束下行时网关调用它的 return(),未消费的项丢弃。 - * 泛型 In 只是调用方的类型断言,运行时按描述符解码,不做交叉校验。 + * The uplink items of this call. Can be taken once; the second call throws. With an uplink codec + * in the descriptor each item is decoded to In; without one, items are delivered as unknown with + * only a JSON-safety check. + * The iteration ends after the Client's end; when the method ends the downlink, the Gateway calls + * its return() and drops unconsumed items. + * The generic In is only the caller's type assertion; runtime decodes per the descriptor and does not cross-check. */ uplink(): AsyncIterable } declare module '@deepseek-ai/cordis' { interface Context { - /** 本 Context 为之派生的 Remote 调用;非 Remote 调用派生的 Context 上为 undefined。 */ + /** The Remote call this Context was derived for; undefined on a Context not derived from a Remote call. */ readonly invocation: RemoteInvocation | undefined } } @@ -165,7 +167,7 @@ declare module '@deepseek-ai/cordis' { #### 调用上下文如何到达方法 -沿用 `remote-client-access` 分支的机制,Cordis 源码确认成立: +机制依赖 Cordis 的两条既有行为: - `ctx.extend(meta)` 是 `Object.create(parent)` 加自有属性(`vendor/cordis/src/context.ts:99-107`),无注册、无 fiber、不需释放,每次调用派生一个是零成本的。 - `ctx.get(service)` 返回 `createTraceable(ctx, service)` 代理(`vendor/cordis/src/reflect.ts:233-234`,`utils.ts:117-125`)。读 `ctx` 属性直接返回访问方的 Context(`utils.ts:175`);读方法时用 `createShadowMethod` 包一层,`apply` 时把 `this` 换成 shadow receiver,shadow 上的 `ctx` 是 `ctx.extend({ [symbols.shadow]: origin })`(`utils.ts:149-163`)。 @@ -174,10 +176,10 @@ declare module '@deepseek-ai/cordis' { ### PeerScope ```text -/** 一个 Peer 的不透明身份。 */ +/** The opaque identity of one Peer. */ export type PeerId = Branded<'PeerId'> -/** 一个 Peer 在本 Host 上的会话:接纳它的一方打开与释放;ctx 拥有连接期注册。 */ +/** One Peer's session on this Host: opened and released by the admitting side; ctx owns connection-lifetime registrations. */ export interface PeerScope { readonly id: PeerId readonly ctx: Context @@ -194,18 +196,18 @@ export interface PeerScope { 网关 `handleUpgrade(req, socket, head, peer)` 为该 socket 绑一个固定 Peer 的 opener,并在 `peer.ctx.effect` 里登记 socket 关闭;scope 已失效时改为直接以 1001 关闭 socket。`InvokeRemoteRequest.peer?` 与 `RemoteStreamOpener` 的 `peer` 形参缺席时按操作者作答,现有进程内调用方零改动;没有 connection 的组合里网关自建的操作者替身同样经 `createScope` 建立,`dispose()` 契约一致。按 id 查 Peer、把载体关联到第二个 Peer、Peer 的开关事件,都等第一个需要多 Peer 的消费者出现时再加。 -不采用的部分:`@Access`、`AccessDeclaration`、`AccessLevel`、`AccessTarget`、`vouch` / `vouched`、`remote/invoke` 与 `remote/deliver` 事件、`mayDeliver`、`requireVoucher`、`gateway/access-denied`。Peer 是谁、能做什么,由业务插件通过 `peer.ctx` 自行附加,与本 Note 无关。 +Peer 不携带访问模型:Peer 是谁、能做什么,由业务插件通过 `peer.ctx` 自行附加。 ### Client 面 ```text -/** 生成方法在 Client 面返回的句柄。与 Host 的 RemoteStream 是两个名字,从同一个入口导出。 */ +/** The handle a generated method returns on the Client face. A different name from the Host's RemoteStream, exported from the same entry point. */ export interface RemoteStreamHandle extends AsyncIterable { - /** 发送一个上行项。非无损 JSON 值、流已终止或已 end 时抛错。 */ + /** Send one uplink item. Throws on a value that is not lossless JSON, after termination, or after end. */ send(item: In): void - /** 上行半关闭:发 end 帧。幂等。 */ + /** Half-close the uplink: sends the end frame. Idempotent. */ end(): void - /** 取消整条逻辑流:发 cancel 帧(未收到终止帧时),下行迭代器安静结束。 */ + /** Cancel the whole logical stream: sends the cancel frame (when no terminal frame has arrived); the downlink iterator ends quietly. */ dispose(): void } ``` @@ -221,11 +223,11 @@ export interface RemoteStreamHandle extends AsyncIterable { ```text export type RemoteStreamClientMessage = | { readonly type: 'open'; readonly streamId: string; readonly endpoint: string; readonly payload: unknown } - | { readonly type: 'item'; readonly streamId: string; readonly value?: unknown } // 新增:上行项 - | { readonly type: 'end'; readonly streamId: string } // 新增:上行半关闭 + | { readonly type: 'item'; readonly streamId: string; readonly value?: unknown } // new: uplink item + | { readonly type: 'end'; readonly streamId: string } // new: uplink half-close | { readonly type: 'cancel'; readonly streamId: string } -/** Host 发出的帧不变。 */ +/** Frames sent by the Host are unchanged. */ export type RemoteStreamServerMessage = | { readonly type: 'item'; readonly streamId: string; readonly value?: unknown } | { readonly type: 'error'; readonly streamId: string; readonly error: RemoteStreamFailure } @@ -239,15 +241,15 @@ export type RemoteStreamServerMessage = #### Host 侧 ``` - open 帧 - (无) ─────────────────────▶ opening ──── opener 解析完成 ────▶ running + open frame + (none) ───────────────────▶ opening ──── opener resolves ────▶ running │ │ - │ item 帧: inbox.push │ item 帧: inbox.push - │ end 帧: inbox.end │ end 帧: inbox.end(上行半关闭) - │ │ 方法 yield: 发 item 帧 - │ cancel 帧 / socket 关闭 ────────▶│ cancel 帧 / socket 关闭: control.abort + │ item frame: inbox.push │ item frame: inbox.push + │ end frame: inbox.end │ end frame: inbox.end (uplink half-close) + │ │ method yield: sends item frame + │ cancel frame / socket close ────▶│ cancel frame / socket close: control.abort ▼ ▼ - aborted ◀───────────────────────── finished(发 end 或 error 帧) + aborted ◀───────────────────────── finished (sends end or error frame) ``` `opening` 期间到达的 `item` 帧进入 inbox:`receive()` 是同步的,`open` 帧处理时就创建 `ActiveStream` 与 inbox,而 opener 是异步的。客户端在 `open` 之后立刻发的项不会丢。 @@ -255,11 +257,11 @@ export type RemoteStreamServerMessage = #### Client 侧 ``` - 调用生成方法 ──▶ 等 socket ──▶ 发 open ──▶ 句柄可用 - │ send(v): 发 item;end(): 发 end - │ 下行:inbox.next() → yield;end → 结束;error → 抛 - │ 下行先终止:send/end 抛错 - │ dispose 或调用方 signal 中止:发 cancel(未收到终止帧时) + call generated method ──▶ await socket ──▶ send open ──▶ handle usable + │ send(v): sends item; end(): sends end + │ downlink: inbox.next() → yield; end → ends; error → throws + │ downlink terminates first: send/end throw + │ dispose or caller signal abort: sends cancel (when no terminal frame has arrived) ``` ### 半关闭与结束语义 @@ -269,7 +271,7 @@ export type RemoteStreamServerMessage = | 客户端 `end` | 仍开放 | Host 的 `uplink()` 迭代结束;方法继续产出。这是 stdin EOF 的形状 | | 仍开放 | Host 方法结束,发 `end` | 客户端句柄终止,之后 `send` / `end` 抛错;Host 调用 `uplink` 迭代器的 `return()`,未消费项丢弃 | | 仍开放 | Host 方法抛错,发 `error` | 同上;客户端下行以 `RemoteError` 失败 | -| 客户端 `dispose` 或 signal 中止 | 任意 | 发 `cancel`;Host `control.abort()`,`cancellableStream` 调方法迭代器的 `return()`,再调 `uplink` 的 `return()`;不发终止帧 | +| 客户端 `dispose` 或 signal 中止 | 任意 | 发 `cancel`;Host `control.abort()`,`cancellableStream` 先关 `uplink`,再调方法迭代器的 `return()`;不发终止帧 | | socket 关闭 | 任意 | Host 中止全部流并等待 `done`;客户端每条流以 `RemoteStreamCarrierError` 失败 | | `end` 之后又收到 `item` | 任意 | 该流以 `gateway/protocol` 错误帧失败并中止;socket 不关闭 | | 未知 `streamId` 的 `item` / `end` | 任意 | 忽略,与 `cancel` 相同:Host 结束流并删除 id 后,客户端在途的上行帧属正常现象,不能连累同一 socket 上的其他流 | @@ -318,13 +320,13 @@ credit 帧留待需要持续大流量上行的消费者出现时再加;inbox export type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } export type PeerId = Branded<'PeerId'> export interface PeerScope { readonly id: PeerId; readonly ctx: Context; dispose(): Promise } -export interface RemoteInvocation { … } // 见 Host 面 +export interface RemoteInvocation { … } // see Host face declare module '@deepseek-ai/cordis' { interface Context { readonly invocation: RemoteInvocation | undefined } } export interface InvocationDescriptor { - // 既有字段不变;mode 仍只有 'stream' + // existing fields unchanged; mode still has only 'stream' readonly mode?: 'stream' - /** 上行项 codec,从返回类型 RemoteStream 的 In 生成;In 为 never 时缺席。 */ + /** Uplink item codec, generated from In of the return type RemoteStream; absent when In is never. */ readonly uplink?: { readonly codec: TypertCodec } readonly cancellation?: { readonly parameter: 'signal' } readonly result: TypertCodec @@ -336,7 +338,7 @@ export interface InvocationDescriptor { ### typert generator(`packages/typert/generator/src`) - `model.ts`:`InvocationModel.uplink?: { boundary: RemoteBoundaryModel }`。 -- `analyzer.ts` `remoteResultType`(现 `~1402`):对 `mode: 'stream'`,接受的返回类型包装器为 `Iterable`、`AsyncIterable`、`RemoteStream`。识别 `RemoteStream` 的方式与识别标准库 `AsyncIterable` 相同:符号名加声明所在文件(`@deepseek-ai/dsh-typert-protocol` 的 `types.ts`)。第一个类型参数是下行项,第二个存在且不是 `never` 时生成 `uplink` boundary,键名 `${endpoint}:uplink`。 +- `analyzer.ts` `remoteResultType`:对 `mode: 'stream'`,接受的返回类型包装器为 `Iterable`、`AsyncIterable`、`RemoteStream`。识别 `RemoteStream` 的方式与识别标准库 `AsyncIterable` 相同:符号名加声明所在文件(`@deepseek-ai/dsh-typert-protocol` 的 `types.ts`)。第一个类型参数是下行项,第二个存在且不是 `never` 时生成 `uplink` boundary,键名 `${endpoint}:uplink`。 - `emitter.ts`:描述符字面量输出 `uplink: { codec }`;生成的 Client 签名返回 `RemoteStreamHandle`。 - 参数循环不再识别任何名为 `uplink` 的参数。 @@ -352,9 +354,9 @@ export interface InvokeRemoteRequest { readonly namespace: string readonly method: string readonly args: Readonly> - /** 上行项;缺席等价于立即结束的迭代器。 */ + /** Uplink items; absent is equivalent to an iterator that ends immediately. */ readonly uplink?: AsyncIterable - /** 发起调用的 Peer;缺席即操作者。 */ + /** The Peer that initiated the call; absent means the operator. */ readonly peer?: PeerScope readonly signal?: AbortSignal } @@ -373,8 +375,8 @@ export type RemoteStreamOpener = ( control: AbortController, ) => Promise> -// index.ts Config 新增 -/** 每条逻辑流的上行 inbox 上限,按帧的 UTF-8 字节计(默认 262144)。 */ +// index.ts Config addition +/** Uplink inbox limit per logical stream, in UTF-8 bytes of frames (default 262144). */ readonly streamInboxBytes?: number ``` @@ -401,23 +403,23 @@ if (descriptor.cancellation !== undefined) args.push(signal) ```text interface ActiveStream { readonly control: AbortController; readonly inbox: UplinkInbox; done: Promise } -/** 有界上行队列;作为 uplink() 的源被迭代。 */ +/** Bounded uplink queue; iterated as the source of uplink(). */ class UplinkInbox implements AsyncIterable, AsyncIterator { constructor(maxBytes: number, onOverflow: (error: RemoteError) => void) - push(value: unknown, frameBytes: number): void // ended 后 push 使流以 gateway/protocol 失败;超限调用 onOverflow - end(): void // 幂等 - fail(error: unknown): void // 取消或载体关闭时让迭代器以错误结束 - next() / return() // 手写:next 与失败竞速,return 立即释放 + push(value: unknown, frameBytes: number): void // push after ended fails the stream with gateway/protocol; overflow calls onOverflow + end(): void // idempotent + fail(error: unknown): void // ends the iterator with an error on cancel or carrier closure + next() / return() // hand-written: next races failure, return releases immediately } ``` `receive(text)` 分派: ``` -open : 已存在 → 抛错(socket 1008);否则创建 { control, inbox, done },启动 pump -item : 不存在 → 忽略;否则 inbox.push(value, bytes) -end : 不存在 → 忽略;否则 inbox.end() -cancel : 不存在 → 忽略;否则 control.abort(new Error('Remote stream cancelled')) +open : exists → throw (socket 1008); otherwise create { control, inbox, done } and start the pump +item : missing → ignore; otherwise inbox.push(value, bytes) +end : missing → ignore; otherwise inbox.end() +cancel : missing → ignore; otherwise control.abort(new Error('Remote stream cancelled')) ``` `pump` 把 inbox 与该 socket 的 Peer 传给 opener;`finally` 里 `inbox.fail(...)`。以 `RemoteError` 为 reason 的中止由 pump 作为 `error` 帧发出(网关或 mux 发起的失败);以普通 `Error` 为 reason 的中止(cancel、socket 关闭)不发终止帧。 @@ -447,40 +449,40 @@ cancel : 不存在 → 忽略;否则 control.abort(new Error('Remote stream ca ### 打开、上行、半关闭、下行结束 ``` -Client 调用方 mux client mux server gateway Host 方法 - │ attach(req) │ │ │ │ - │───────────────────▶│ open { id, ep, payload } ─────────────────▶│ 创建 inbox │ - │ s.send(v1) │ item { id, v1 } ──────────────────────────▶│ inbox.push(v1) │ - │ │ │ │ prepareInvocation │ - │ │ │ │ extend({ invocation }) │ - │ │ │ │───── method(req, signal) ─────────▶│ - │ │ │ │ │ uplink() → v1 - │ s.send(v2) │ item { id, v2 } ──────────────────────────▶│ inbox.push(v2) │ → v2 - │ s.end() │ end { id } ───────────────────────────────▶│ inbox.end() │ 迭代结束 - │◀── yield o1 ───────│◀────────────────────── item { id, o1 } ────│◀── yield o1 ────────│ - │◀── 结束 ───────────│◀────────────────────── end { id } ─────────│◀── 方法返回 ─────────│ gateway 调 uplink.return() +Client caller mux client mux server gateway Host method + │ attach(req) │ │ │ │ + │───────────────────▶│ open { id, ep, payload } ──────────────────▶│ create inbox │ + │ s.send(v1) │ item { id, v1 } ───────────────────────────▶│ inbox.push(v1) │ + │ │ │ │ prepareInvocation │ + │ │ │ │ extend({ invocation }) │ + │ │ │ │── method(req, signal) ─▶│ + │ │ │ │ │ uplink() → v1 + │ s.send(v2) │ item { id, v2 } ───────────────────────────▶│ inbox.push(v2) │ → v2 + │ s.end() │ end { id } ────────────────────────────────▶│ inbox.end() │ iteration ends + │◀── yield o1 ───────│◀────────────────────── item { id, o1 } ─────│◀── yield o1 ────────────│ + │◀── ends ───────────│◀────────────────────── end { id } ──────────│◀── method returns ──────│ gateway calls uplink.return() ``` ### 客户端 dispose ``` -Client 调用方 mux client mux server gateway Host 方法 - │ s.dispose() │ │ │ │ - │───────────────────▶│ 停泵;源 return()(不等待) │ │ - │◀── 迭代器结束 ─────│ cancel { id } ────────────────────────────▶│ control.abort() │ - │ │ │ │ cancellableStream: iterator.return() ──▶│ finally 清理 - │ │ │ │ uplink.return() │ - │ │ │ 不发终止帧 │ │ +Client caller mux client mux server gateway Host method + │ s.dispose() │ │ │ │ + │───────────────────▶│ stop pump; source return() (not awaited) │ │ + │◀── iterator ends ──│ cancel { id } ─────────────────────────────▶│ control.abort() │ + │ │ │ │ cancellableStream: invocation.close() closes uplink + │ │ │ │ iterator.return() ─────▶│ finally cleanup + │ │ │ no terminal frame │ │ ``` ### inbox 超限 ``` -Client 调用方 mux client mux server gateway / Host 方法 +Client caller mux client mux server gateway / Host method │ s.send(vN) │ item { id, vN } ─────▶│ inbox.push: bytes > streamInboxBytes │ │ │ │ onOverflow → control.abort(RemoteError uplink-overflow) - │ │ │ │ uplink() 以该错误结束;方法迭代器 return() - │◀── throw ──────────│◀── error { id, uplink-overflow } ─────────│ pump 因 reason 是 RemoteError 发 error 帧 + │ │ │ │ uplink() ends with that error; method iterator return() + │◀── throw ──────────│◀──────────────────────│ error { id, uplink-overflow }: the pump sends an error frame because the reason is a RemoteError ``` ## 边界矩阵 @@ -527,7 +529,7 @@ class EchoService extends TypertRemoteService { } } -// 客户端 +// Client const stream = remote.echo.echo('> ') stream.send('a'); stream.send('b'); stream.end() const replies: string[] = [] @@ -560,7 +562,7 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b'] ## 后果 -- **买到的**:一条流两个方向,终端按键、审批回答、后台任务 stdin 这类交互式场景不再各造一套"一条流加一个 unary";Host 方法知道调用者;上行类型与下行类型在返回类型里一处声明,上行项与下行项同样逐项严格校验;既有 `AsyncIterable` 方法与 unary 方法的生成物与线路帧完全不变。 +- **买到的**:一条流两个方向,终端按键、审批回答、后台任务 stdin 这类交互式场景不再各造一套"一条流加一个 unary";Host 方法知道调用者;上行类型与下行类型在返回类型里一处声明,上行项来自浏览器,在 Host 逐项严格校验,下行项是 Host 自产的类型值,直接透传;既有 `AsyncIterable` 方法与 unary 方法的生成物与线路帧完全不变。 - **`this.ctx.invocation` 在非 Remote 调用下是 `undefined`**,方法读它要处理可选;一个只被进程内直接调用的服务方法读到 `undefined` 是正确行为。 - **inbox 上限对所有流生效**:一个不读上行的方法收到大量上行帧会整流失败。这是刻意的显式失败,README 记明。 - **Peer 只有操作者一个**,`admit` 是唯一的接纳点;第一个需要第二个 Peer 的消费者要在 `dsh-client-connection` 加上打开、关联与事件。 diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml index cfbf30d80a..76ed3e8786 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.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 .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md -2026-09-09-web-sidebar-terminal.md: 5ed920d744f847cc97b08645592f0be221f18b8d -2026-09-09-web-sidebar-terminal.zh.md: c3ceb0214c2f4db42b2fefb8b65619e198d9dc7e +2026-09-09-web-sidebar-terminal.md: 09be4a142f615750799e01106eac7a9b52cbad48 +2026-09-09-web-sidebar-terminal.zh.md: c2ac1468756ee08c713855979119662390ffeadb diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md index 5ed920d744..09be4a142f 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md @@ -49,3 +49,7 @@ A kept-open terminal retains a process and bounded screen memory. Reload restore The implementation retains the Agent-terminal and portable-execution notes because their ownership and provider decisions remain independently useful; neither is superseded by browser terminals. The [two-hour unattended-terminal reclamation](2026-09-14-unattended-browser-terminal-reclamation.md) adds an idle grace period after all frontend holders disconnect, preserving busy or uncertain work. + +## Related + +- [Remote duplex stream](../architecture/2026-09-19-remote-duplex-stream.md): the transport-layer uplink under which one `attach` stream replaces the unary `write` and `resize` calls and the `attachmentId` correlation; that terminal change is listed under its Deferred section. diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md index c3ceb0214c..c2ac146875 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md @@ -49,3 +49,7 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命 Agent 终端和可移植执行环境两篇记录仍保留,其所有权与 provider 决策继续独立有效,不被浏览器终端取代。 [无人连接终端的两小时回收](2026-09-14-unattended-browser-terminal-reclamation.zh.md)在全部前端持有者断开后设置空闲宽限期,保留忙碌或状态不确定的工作。 + +## 相关 + +- [Remote 双工流](../architecture/2026-09-19-remote-duplex-stream.zh.md):传输层上行,让一条 `attach` 流取代 unary `write`、`resize` 与 `attachmentId` 关联;该终端改动列在其「后续」中。 From ffea3c8818d2d1e0e62ef3d84dc6e985b0b069a8 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 20 Sep 2026 15:12:09 +0800 Subject: [PATCH 31/60] fix(gateway): close the remaining uplink lifecycle gaps --- docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 2 +- docs/api-gateway.zh.md | 2 +- docs/subsystems/typert.i18n.yaml | 4 +- docs/subsystems/typert.md | 16 ++-- docs/subsystems/typert.zh.md | 16 ++-- packages/api/gateway/README.i18n.yaml | 4 +- packages/api/gateway/README.md | 2 +- packages/api/gateway/README.zh.md | 2 +- packages/api/gateway/src/client/index.ts | 22 ++++-- .../api/gateway/src/client/remote-events.ts | 3 +- packages/api/gateway/src/index.ts | 23 ++++-- packages/api/gateway/src/stream-protocol.ts | 38 +--------- packages/api/gateway/src/types.ts | 3 +- .../gateway/tests/gateway-stream.host.spec.ts | 60 +++++++++++++++ .../api/gateway/tests/gateway.client.spec.ts | 60 +++++++++++++++ .../tests/remote-event-protocol.host.spec.ts | 2 +- .../webworker-runtime/src/client/client.ts | 76 ++++++++++++------- .../tests/transport/tunnel-client.spec.ts | 21 +++++ .../test-support/remote-mock/README.i18n.yaml | 4 +- packages/test-support/remote-mock/README.md | 4 +- .../test-support/remote-mock/README.zh.md | 4 +- .../test-support/remote-mock/package.json | 3 +- .../remote-mock/src/remote-mock.ts | 2 +- .../test-support/remote-mock/src/streams.ts | 24 ++++-- .../remote-mock/tests/rpc.client.spec.ts | 35 +++++++++ packages/typert/protocol/README.i18n.yaml | 4 +- packages/typert/protocol/README.md | 3 +- packages/typert/protocol/README.zh.md | 3 +- packages/typert/protocol/src/index.ts | 11 ++- packages/typert/protocol/src/json-value.ts | 53 +++++++++++++ packages/typert/protocol/src/types.ts | 16 ++-- .../typert/protocol/tests/protocol.spec.ts | 18 ++++- 33 files changed, 410 insertions(+), 134 deletions(-) create mode 100644 packages/typert/protocol/src/json-value.ts diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 4515aa50dd..018bb1abc0 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.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/api-gateway.md -api-gateway.md: 490a9fea0d4057d1e29ac37ad8693dedce1b6e16 -api-gateway.zh.md: 63f13140602f50c04e7aaa541cb4e0662c3c278d +api-gateway.md: 52e91c48104ac8a8a46ad27f2b329ca72d6abc06 +api-gateway.zh.md: 9f062eaa6e6618354afa9ed1101e9cf6ed67d465 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 490a9fea0d..52e91c4810 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -55,7 +55,7 @@ export class GoalService extends TypertRemoteService { Remote methods may return a value synchronously or return a Promise. For cooperative cancellation, the final parameter in the Host signature must be `signal: AbortSignal` using the global type; it is recorded in the descriptor instead of entering `args`, while the generated Client method accepts an optional final `AbortSignal`. -`@Remote({ mode: 'stream' })` marks a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream`: the Gateway validates and delivers each yielded item over its multiplexed `/api/remote.mux` WebSocket or an in-process carrier, and the generated Client method returns a `RemoteStreamHandle` that iterates the items and exposes `send`, `end`, and `dispose` for the Client-to-Host uplink of the same logical stream. The second type argument of `RemoteStream` declares the uplink item type; the Gateway validates each item the Client sends with the generated `In` codec before the Host method reads it through `this.ctx.invocation.uplink()`. The uplink enters neither `args` nor the parameter list; the [Gateway README](../packages/api/gateway/README.md) owns the frame, half-close, cancellation, and inbox-bound contracts. +`@Remote({ mode: 'stream' })` marks a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream`: the Gateway delivers each yielded item, a value the Host method produced, over its multiplexed `/api/remote.mux` WebSocket or an in-process carrier, and the generated Client method returns a `RemoteStreamHandle` that iterates the items and exposes `send`, `end`, and `dispose` for the Client-to-Host uplink of the same logical stream. The second type argument of `RemoteStream` declares the uplink item type; the Gateway validates each item the Client sends, because it arrives from the browser, with the generated `In` codec before the Host method reads it through `this.ctx.invocation.uplink()`. The uplink enters neither `args` nor the parameter list; the [Gateway README](../packages/api/gateway/README.md) owns the frame, half-close, cancellation, and inbox-bound contracts. The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. Direct and scoped calls appear under `ctx.remote.` and `agentCtx.remote.`. Each namespace is a traced Cordis child Service registered as `remote.`; the Client assembly mounts contributions through `ctx.remote.$mount()`, and the namespace unloads after its last method is withdrawn. Dependency declarations belong to the actual caller: only a business package that reads `ctx.remote.` or `agentCtx.remote.` declares both `remote` and `remote.` in its own `inject`; assemblies that only mount contributions and higher-level runtimes that do not call that namespace do not declare the namespace dependency on the business package's behalf. When an `@Remote` method has exactly one lookup parameter and a same-named `TypertContextMap` uses the same wire identity, the generated scoped signature omits that identity parameter. `@RemoteScope` generates only the scoped invocation interface. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 63f1314060..9f062eaa6e 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -55,7 +55,7 @@ export class GoalService extends TypertRemoteService { Remote 方法可以同步返回或返回 Promise。若需要协作式取消,Host 签名的最后一个参数必须是全局类型的 `signal: AbortSignal`;它记录在描述符中而不是进入 `args`,Client 生成的方法则接受最后一个可选的 `AbortSignal`。 -`@Remote({ mode: 'stream' })` 标记返回 `Iterable`、`AsyncIterable` 或 `RemoteStream` 的方法:Gateway 校验每个产出的项,并经其多路复用的 `/api/remote.mux` WebSocket 或进程内载体投递,Client 生成的方法返回一个 `RemoteStreamHandle`,它迭代这些项,并为同一条逻辑流上从 Client 到 Host 的上行提供 `send`、`end` 与 `dispose`。`RemoteStream` 的第二个类型参数声明上行项类型;Gateway 在 Host 方法通过 `this.ctx.invocation.uplink()` 读取之前,用生成的 `In` codec 校验 Client 发送的每一项。上行既不进入 `args` 也不进入参数列表;帧、半关闭、取消与 inbox 上限的约定由 [Gateway README](../packages/api/gateway/README.zh.md) 拥有。 +`@Remote({ mode: 'stream' })` 标记返回 `Iterable`、`AsyncIterable` 或 `RemoteStream` 的方法:Gateway 把每个产出的项(Host 方法产出的值)经其多路复用的 `/api/remote.mux` WebSocket 或进程内载体投递,Client 生成的方法返回一个 `RemoteStreamHandle`,它迭代这些项,并为同一条逻辑流上从 Client 到 Host 的上行提供 `send`、`end` 与 `dispose`。`RemoteStream` 的第二个类型参数声明上行项类型;Gateway 在 Host 方法通过 `this.ctx.invocation.uplink()` 读取之前,用生成的 `In` codec 校验 Client 发送的每一项——它们来自浏览器。上行既不进入 `args` 也不进入参数列表;帧、半关闭、取消与 inbox 上限的约定由 [Gateway README](../packages/api/gateway/README.zh.md) 拥有。 Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接调用与作用域调用分别出现在 `ctx.remote.` 和 `agentCtx.remote.`。每个 namespace 都是注册为 `remote.` 的可追踪 Cordis 子服务;Client assembly 通过 `ctx.remote.$mount()` 挂载贡献,最后一个方法撤回后该 namespace 随即卸载。依赖声明归实际调用方所有:只有读取 `ctx.remote.` 或 `agentCtx.remote.` 的业务包才在自己的 `inject` 中同时声明 `remote` 与 `remote.`;只负责挂载 contribution 的 assembly,以及不调用该 namespace 的上层运行时,不代业务包声明 namespace 依赖。当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypertContextMap` 使用相同 wire identity 时,生成的作用域签名会省略该 identity 参数。`@RemoteScope` 只生成作用域调用接口。 diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index df4e0cdc23..2f43dc6d54 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/typert.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/typert.md -typert.md: d7d109c0f4cd02baed42d30a21a2fbf36da9ec2a -typert.zh.md: 0666afb1a10dc4c9056c993ccfe23b9f8f9e4f19 +typert.md: a8192b319215957b8d373ec01f6f29d73e165e45 +typert.zh.md: 08d1e8f466ef1e516c04426165b8180bd9881c83 diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index d7d109c0f4..a8192b3192 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -85,7 +85,7 @@ interface InvocationDescriptor { readonly method: string /** Service member invoked when the exported method name is an alias. */ readonly implementation?: string - /** Absent for unary calls; stream calls validate and deliver every yielded item. */ + /** Absent for unary calls; stream calls deliver every yielded item as the Host produced it. */ readonly mode?: 'stream' /** Receiver selection mode. */ readonly invocation: @@ -148,18 +148,22 @@ type RemoteStream = AsyncIterable & { readonly [STREAM_UPL /** * One open Remote stream as the Client holds it: the downlink items as an * `AsyncIterable`, plus the uplink and cancellation of the same logical - * stream. A generated Client stream method returns it. A handle stands for - * one generation: when the carrier is lost, iteration fails with the carrier - * error and the handle is finished. + * stream. A generated Client stream method returns it, and calling that + * method opens the stream: a holder that neither iterates nor disposes the + * handle keeps the Host stream alive. A handle stands for one generation: + * when the carrier is lost, iteration fails with the carrier error and the + * handle is finished. * @template Out - item type the Host method yields. * @template In - item type the Client may send; `never` when the method reads none. */ interface RemoteStreamHandle extends AsyncIterable { /** * Send one uplink item. Items sent before the stream has opened are queued - * and sent once the `open` frame is on the wire. + * and sent once the `open` frame is on the wire. A top-level `undefined` + * travels as an `item` frame without `value`. * @param item - item the Host validates against the method's uplink codec. - * @throws {Error} when `end()` was called or the stream has terminated. + * @throws {Error} when the item is not a lossless JSON value, when `end()` + * was called, or once the stream has terminated. */ send(item: In): void /** Half-close the uplink: the Host's `uplink()` iteration ends. Idempotent; ignored after termination. */ diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index 0666afb1a1..08d1e8f466 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -85,7 +85,7 @@ interface InvocationDescriptor { readonly method: string /** Service member invoked when the exported method name is an alias. */ readonly implementation?: string - /** Absent for unary calls; stream calls validate and deliver every yielded item. */ + /** Absent for unary calls; stream calls deliver every yielded item as the Host produced it. */ readonly mode?: 'stream' /** Receiver selection mode. */ readonly invocation: @@ -148,18 +148,22 @@ type RemoteStream = AsyncIterable & { readonly [STREAM_UPL /** * One open Remote stream as the Client holds it: the downlink items as an * `AsyncIterable`, plus the uplink and cancellation of the same logical - * stream. A generated Client stream method returns it. A handle stands for - * one generation: when the carrier is lost, iteration fails with the carrier - * error and the handle is finished. + * stream. A generated Client stream method returns it, and calling that + * method opens the stream: a holder that neither iterates nor disposes the + * handle keeps the Host stream alive. A handle stands for one generation: + * when the carrier is lost, iteration fails with the carrier error and the + * handle is finished. * @template Out - item type the Host method yields. * @template In - item type the Client may send; `never` when the method reads none. */ interface RemoteStreamHandle extends AsyncIterable { /** * Send one uplink item. Items sent before the stream has opened are queued - * and sent once the `open` frame is on the wire. + * and sent once the `open` frame is on the wire. A top-level `undefined` + * travels as an `item` frame without `value`. * @param item - item the Host validates against the method's uplink codec. - * @throws {Error} when `end()` was called or the stream has terminated. + * @throws {Error} when the item is not a lossless JSON value, when `end()` + * was called, or once the stream has terminated. */ send(item: In): void /** Half-close the uplink: the Host's `uplink()` iteration ends. Idempotent; ignored after termination. */ diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 95e9625c6c..5f05ce5e1e 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/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/api/gateway/README.md -README.md: 22ffbe01623b817968752e5f19e2c1aca03cd212 -README.zh.md: edf4d7ae9ee66cf7156c1aea87a2330b475e3732 +README.md: 996e68fd2b99989523b01272c693d4039fdc9f44 +README.zh.md: 23b83fbf2c7559434dfd47d6b06994a8d390d1ac diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 22ffbe0162..996e68fd2b 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -34,7 +34,7 @@ A cancellation-aware Remote method declares `signal: AbortSignal` as its final H A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then returns a cancellation-aware iterable over the business items. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates and keeps it connected while idle. Connection owns the retry schedule; before each retry it asks the mux to cancel any candidate or active socket and make exactly one fresh physical attempt. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (two seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. A socket that has not answered the previous Ping is terminated at the next interval. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. -Every stream also carries a Client-to-Host uplink on the same logical stream. A method declares the uplink item type as the second type argument of its return type, `RemoteStream` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `In` defaults to `never`, and a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream` declares that it reads none. The running method reads its call as `this.ctx.invocation`: the Gateway derives the receiver from a Context carrying that `RemoteInvocation` — `request`, `service`, `peer`, `signal`, and `uplink()` — so nothing enters the parameter list, and `ctx.invocation` is `undefined` on a Context no Remote call derived. `uplink()` is available once per call. With a descriptor codec it decodes every item before delivery; without one — an SRC method, or a method whose `In` is `never` — it delivers JSON-safe `unknown` values. The Client sends each item as an `item` frame on the open logical stream and its half-close as an `end` frame; the method observes the half-close as the end of the `uplink()` iteration and cancellation through `signal`. A rejected item fails the whole stream with `gateway/input-invalid` (field `uplink`). When the method finishes its downlink the Gateway returns the uplink iterator and drops unread items; items sent to a method that never takes its uplink wait in the inbox until then. Each logical stream buffers at most `streamInboxBytes` (262144 by default) of uplink frame bytes; overflow fails the stream with `gateway/uplink-overflow`, and an item after `end` fails it with `gateway/protocol`, in both cases without closing the socket. `item`, `end`, and `cancel` frames for a stream id the Host has already finished are dropped; only a duplicate `open` closes the socket. A unary method may also call `uplink()`; its items are readable only while the method runs. Every stream speaks for the operator Peer: a WebSocket's streams for the Peer Connection admitted at upgrade, whose scope disposal closes the socket, and an in-process carrier's for the operator directly. +Every stream also carries a Client-to-Host uplink on the same logical stream. A method declares the uplink item type as the second type argument of its return type, `RemoteStream` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `In` defaults to `never`, and a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream` declares that it reads none. The running method reads its call as `this.ctx.invocation`: the Gateway derives the receiver from a Context carrying that `RemoteInvocation` — `request`, `service`, `peer`, `signal`, and `uplink()` — so nothing enters the parameter list, and `ctx.invocation` is `undefined` on a Context no Remote call derived. `uplink()` is available once per call. With a descriptor codec it decodes every item before delivery; without one — an SRC method, or a method whose `In` is `never` — it delivers JSON-safe `unknown` values. The Client sends each item as an `item` frame on the open logical stream, a top-level `undefined` item as an `item` frame without `value`, and its half-close as an `end` frame; the method observes the half-close as the end of the `uplink()` iteration and cancellation through `signal`. A rejected item fails the whole stream with `gateway/input-invalid` (field `uplink`). Uplink items are validated one by one at the Host because they arrive from the browser; downlink items are values the Host method produced and pass through unchecked. When the method finishes its downlink the Gateway returns the uplink iterator and drops unread items; items sent to a method that never takes its uplink wait in the inbox until then. Each logical stream buffers at most `streamInboxBytes` (262144 by default) of uplink frame bytes; overflow fails the stream with `gateway/uplink-overflow`, and an item after `end` fails it with `gateway/protocol`, in both cases without closing the socket. `item`, `end`, and `cancel` frames for a stream id the Host has already finished are dropped; only a duplicate `open` closes the socket. Gateway-owned streams such as `$events` return their uplink as soon as they open, so their `item` frames are dropped instead of buffered. A unary method may also call `uplink()`; its items are readable only while the method runs. Every stream speaks for the operator Peer: a WebSocket's streams for the Peer Connection admitted at upgrade, whose scope disposal closes the socket, and an in-process carrier's for the operator directly. Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, per-Client queues, and the Host home sent in the opening `{ type: 'ready', clientId, host: { home } }` frame. Its source factory attaches incremental listeners synchronously, so the Client publishes the generation and starts baseline reads only after incremental delivery is ready. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index edf4d7ae9e..23b83fbf2c 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -34,7 +34,7 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle 流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再返回可取消的业务项 iterable。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,并让它在空闲时保持连接。Connection 拥有重试调度;每次 retry 前,它要求 mux 取消候选或活动 socket,并且只做一次全新的物理连接尝试。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 2 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote 流帧。若 socket 尚未回复上一次 Ping,Host 会在下一间隔终止它。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 -每条流还在同一条逻辑流上携带从 Client 到 Host 的上行。方法把上行项类型声明为返回类型的第二个类型参数——来自 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `RemoteStream`;`In` 缺省为 `never`,返回 `Iterable`、`AsyncIterable` 或 `RemoteStream` 的方法即声明自己不读上行。运行中的方法通过 `this.ctx.invocation` 读取本次调用:Gateway 从携带该 `RemoteInvocation`(`request`、`service`、`peer`、`signal` 与 `uplink()`)的 Context 派生接收者,因此没有任何东西进入参数列表,而非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`。`uplink()` 每次调用只能取一次。描述符带 codec 时它在交付前逐项解码;不带时——SRC 方法,或 `In` 为 `never` 的方法——它交付经 JSON 安全校验的 `unknown` 值。Client 在已打开的逻辑流上把每一项作为 `item` 帧发送,以 `end` 帧表示半关闭;方法通过 `uplink()` 迭代结束感知半关闭,通过 `signal` 感知取消。被拒绝的项使整条流以 `gateway/input-invalid`(字段 `uplink`)失败。方法结束下行时 Gateway 调用 uplink 迭代器的 return 并丢弃未读的项;发给从未取用 uplink 的方法的项在 inbox 里等到那时为止。每条逻辑流最多缓冲 `streamInboxBytes`(默认 262144)字节的上行帧;超限使流以 `gateway/uplink-overflow` 失败,`end` 之后的项使流以 `gateway/protocol` 失败,两种情况都不关闭 socket。发给 Host 已结束的流 id 的 `item`、`end` 与 `cancel` 帧被丢弃;只有重复的 `open` 会关闭 socket。一元方法也可以调用 `uplink()`;其项只在方法运行期间可读。每条流都代表操作者 Peer:WebSocket 上的流代表 Connection 在升级时接纳的 Peer,该 scope 释放时 socket 关闭;进程内载体的流直接代表操作者。 +每条流还在同一条逻辑流上携带从 Client 到 Host 的上行。方法把上行项类型声明为返回类型的第二个类型参数——来自 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `RemoteStream`;`In` 缺省为 `never`,返回 `Iterable`、`AsyncIterable` 或 `RemoteStream` 的方法即声明自己不读上行。运行中的方法通过 `this.ctx.invocation` 读取本次调用:Gateway 从携带该 `RemoteInvocation`(`request`、`service`、`peer`、`signal` 与 `uplink()`)的 Context 派生接收者,因此没有任何东西进入参数列表,而非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`。`uplink()` 每次调用只能取一次。描述符带 codec 时它在交付前逐项解码;不带时——SRC 方法,或 `In` 为 `never` 的方法——它交付经 JSON 安全校验的 `unknown` 值。Client 在已打开的逻辑流上把每一项作为 `item` 帧发送(顶层 `undefined` 项是不带 `value` 的 `item` 帧),以 `end` 帧表示半关闭;方法通过 `uplink()` 迭代结束感知半关闭,通过 `signal` 感知取消。被拒绝的项使整条流以 `gateway/input-invalid`(字段 `uplink`)失败。上行项在 Host 逐项校验,因为它们来自浏览器;下行项是 Host 方法产出的值,不经校验原样透传。方法结束下行时 Gateway 调用 uplink 迭代器的 return 并丢弃未读的项;发给从未取用 uplink 的方法的项在 inbox 里等到那时为止。每条逻辑流最多缓冲 `streamInboxBytes`(默认 262144)字节的上行帧;超限使流以 `gateway/uplink-overflow` 失败,`end` 之后的项使流以 `gateway/protocol` 失败,两种情况都不关闭 socket。发给 Host 已结束的流 id 的 `item`、`end` 与 `cancel` 帧被丢弃;只有重复的 `open` 会关闭 socket。`$events` 这类 Gateway 自有的流在打开时立即 return 其 uplink,因此它们的 `item` 帧被丢弃而不缓冲。一元方法也可以调用 `uplink()`;其项只在方法运行期间可读。每条流都代表操作者 Peer:WebSocket 上的流代表 Connection 在升级时接纳的 Peer,该 scope 释放时 socket 关闭;进程内载体的流直接代表操作者。 Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的流。事件名单、参数校验、每个 Client 的队列及 opening `{ type: 'ready', clientId, host: { home } }` frame 中的 Host home 由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener,因此 Client 只在增量投递就绪后发布 generation 并开始 baseline 读取。 diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index c189fa464b..43992cb60e 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -5,7 +5,7 @@ */ import { Service } from '@deepseek-ai/cordis' -import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError, isRemoteUplinkItem, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' export type { TypertGatewayFaultDetails } from '../remote-error-codes.ts' import type { Context } from '@deepseek-ai/cordis' import type { @@ -28,7 +28,6 @@ import { RemoteStreamCarrierError, RemoteStreamMuxClient, } from './stream-client.ts' -import { isRemoteJsonValue } from '../stream-protocol.ts' import { ClientRemoteEvents } from './remote-events.ts' import { RemoteStream, @@ -553,13 +552,14 @@ class ClientStreamHandle implements RemoteStreamHandle { this.downlink = downlink[Symbol.asyncIterator]() // The carrier opens the logical stream on the first pull; pulling now puts // the `open` frame on the wire before any `send()`. The first read is kept - // for the consumer, and a failure waits for it instead of surfacing here. + // for the consumer, and a failure waits for it instead of surfacing here; + // it also terminates the stream, so the queue closes and `send()` throws. this.primed = this.downlink.next() - void this.primed.catch(() => undefined) + void this.primed.catch(() => { this.uplink.close() }) } send(item: unknown): void { - if (!isRemoteJsonValue(item)) throw new Error(`client api: ${this.endpoint} uplink item is not a lossless JSON value`) + if (!isRemoteUplinkItem(item)) throw new Error(`client api: ${this.endpoint} uplink item is not a lossless JSON value`) this.uplink.push(item) } @@ -577,10 +577,18 @@ class ClientStreamHandle implements RemoteStreamHandle { void Promise.resolve(this.downlink.return?.()).catch(() => undefined) } - [Symbol.asyncIterator](): AsyncGenerator { + [Symbol.asyncIterator](): AsyncIterator { if (this.consumed) throw new Error(`client api: ${this.endpoint} stream has one consumer`) this.consumed = true - return this.iterate() + const iteration = this.iterate() + return { + next: () => iteration.next(), + // A return before the first read ends a generator that never started, so its `finally` would not dispose. + return: (value?: unknown) => { + this.dispose() + return iteration.return(value) + }, + } } private async *iterate(): AsyncGenerator { diff --git a/packages/api/gateway/src/client/remote-events.ts b/packages/api/gateway/src/client/remote-events.ts index d799648b98..eabda54e37 100644 --- a/packages/api/gateway/src/client/remote-events.ts +++ b/packages/api/gateway/src/client/remote-events.ts @@ -11,7 +11,7 @@ import type { TypertOwnedValue, TypertRemoteEvent, } from '@deepseek-ai/dsh-typert-protocol' -import { isTypertOwnedValue } from '@deepseek-ai/dsh-typert-protocol' +import { isRemoteJsonValue, isTypertOwnedValue } from '@deepseek-ai/dsh-typert-protocol' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import { REMOTE_EVENT_RESULT_ENDPOINT, @@ -20,7 +20,6 @@ import { isRemoteEventAgentId, isRemoteEventClientId, isRemoteEventId, - isRemoteJsonValue, projectRemoteEventRejection, type RemoteEventClientId, type RemoteEventDownlinkFrame, diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 46ac166101..02b4c7fbbb 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -15,6 +15,7 @@ import z from '@deepseek-ai/schemastery' export type { TypertGatewayFaultDetails } from './remote-error-codes.ts' import { RemoteError, + isRemoteJsonValue, remoteErrorOf, remoteMethods, type InvocationDescriptor, @@ -45,7 +46,6 @@ import { REMOTE_EVENT_RESULT_ENDPOINT, REMOTE_STREAM_MUX_PATH, isRemoteEventAgentId, - isRemoteJsonValue, parseRemoteEventResult, projectRemoteEventRequest, restoreRemoteEventRejection, @@ -419,6 +419,8 @@ export class TypertGatewayService extends Service implements TypertGateway { control: AbortController, ): Promise> { if (endpoint === REMOTE_EVENT_STREAM_ENDPOINT) { + // A Gateway-owned stream reads no uplink: releasing it now keeps its items out of the bounded inbox. + releaseUplink(uplink) return this.openRemoteEvents(payload, signal) } return this.openStream({ ...remoteRequest(endpoint, payload, signal, peer), uplink }, control) @@ -1156,7 +1158,10 @@ class UplinkDecoder implements AsyncIterable, AsyncIterator { } let value: unknown try { - value = decode(this.codec, next.value, this.endpoint, 'uplink') + // A top-level `undefined` is the absent `value` of its frame: without a codec it is the item itself. + value = next.value === undefined && this.codec.mode === 'src-json' + ? undefined + : decode(this.codec, next.value, this.endpoint, 'uplink') } catch (failure) { // The codec failure (`gateway/input-invalid`, field `uplink`) fails the whole logical stream. this.abort(failure) @@ -1230,12 +1235,20 @@ class GatewayInvocation implements RemoteInvocation { return } this.taken = true - // The carrier owns the source iterator; its return() is not awaited for - // the same reason UplinkDecoder.return() does not await it. - void Promise.resolve().then(() => this.uplink_.source[Symbol.asyncIterator]().return?.()).catch(() => undefined) + releaseUplink(this.uplink_.source) } } +/** + * Return a carrier uplink nobody will read, so it drops later items instead of + * buffering them. The carrier owns the iterator and `return()` is not awaited: + * a generator blocked in `next()` completes it only once it yields. + * @param source - the carrier's uplink iterable. + */ +function releaseUplink(source: AsyncIterable): void { + void Promise.resolve().then(() => source[Symbol.asyncIterator]().return?.()).catch(() => undefined) +} + function rpcFailure(error: unknown): ConnectionRpcResult { const remote = remoteErrorOf(error) if (remote !== undefined) { diff --git a/packages/api/gateway/src/stream-protocol.ts b/packages/api/gateway/src/stream-protocol.ts index a66bdfe137..610fc07780 100644 --- a/packages/api/gateway/src/stream-protocol.ts +++ b/packages/api/gateway/src/stream-protocol.ts @@ -1,6 +1,7 @@ /** Wire messages for Gateway-owned Remote streams and event-result RPCs. */ import type { Branded } from '@deepseek-ai/dsh-brand' +import { isRemoteJsonValue } from '@deepseek-ai/dsh-typert-protocol' /** Exact WebSocket route carrying every Typert Remote stream. */ export const REMOTE_STREAM_MUX_PATH = '/api/remote.mux' @@ -203,15 +204,6 @@ export function restoreRemoteEventRejection(rejection: RemoteEventRejection): Er return error } -/** - * Test whether a value crosses JSON transport without coercion or omission. - * @param value - candidate boundary value. - * @returns whether the value is losslessly JSON-compatible. - */ -export function isRemoteJsonValue(value: unknown): boolean { - return visitJsonValue(value, new Set()) -} - /** * Recognize a non-empty Remote Event correlation id at a wire boundary. * @param value - untrusted wire value. @@ -387,31 +379,3 @@ function stringProperty(value: object | undefined, key: string): string | undefi const candidate: unknown = Reflect.get(value, key) return typeof candidate === 'string' ? candidate : undefined } - -function visitJsonValue(value: unknown, ancestors: Set): boolean { - if (value === null || typeof value === 'string' || typeof value === 'boolean') return true - if (typeof value === 'number') return Number.isFinite(value) && !Object.is(value, -0) - if (typeof value !== 'object') return false - if (ancestors.has(value)) return false - ancestors.add(value) - try { - if (Array.isArray(value)) { - if (Object.getPrototypeOf(value) !== Array.prototype - || Reflect.ownKeys(value).length !== value.length + 1) return false - for (let index = 0; index < value.length; index++) { - if (!Object.hasOwn(value, index) || !visitJsonValue(value[index], ancestors)) return false - } - return true - } - const prototype: unknown = Object.getPrototypeOf(value) - if (prototype !== Object.prototype && prototype !== null) return false - for (const key of Reflect.ownKeys(value)) { - if (typeof key !== 'string') return false - const descriptor = Object.getOwnPropertyDescriptor(value, key) - if (descriptor?.enumerable !== true || !visitJsonValue(Reflect.get(value, key), ancestors)) return false - } - return true - } finally { - ancestors.delete(value) - } -} diff --git a/packages/api/gateway/src/types.ts b/packages/api/gateway/src/types.ts index 3176da05ae..cae8c9dcaf 100644 --- a/packages/api/gateway/src/types.ts +++ b/packages/api/gateway/src/types.ts @@ -86,7 +86,8 @@ export interface TypertGatewayWireStream { * Open one logical stream from its wire endpoint and payload. * @param endpoint - canonical Remote endpoint or Gateway-owned stream name. * @param payload - decoded carrier payload. - * @param uplink - Client-to-Host items of the logical stream; Gateway-owned endpoints ignore them. + * @param uplink - Client-to-Host items of the logical stream; a Gateway-owned endpoint returns its iterator + * as soon as it opens, so the carrier drops those items instead of buffering them. * @param peer - Peer the stream speaks for; `undefined` means the operator's in-process carrier. * @param signal - logical-stream cancellation. * @returns validated stream values. diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index 74e2edf3e3..7ed908f620 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -499,6 +499,21 @@ describe('Typert Remote streams', () => { }))).resolves.toEqual(['a', 'b', 'done']) }) + it('accepts a top-level undefined uplink item where no codec applies and rejects it where one does', async () => { + const { ctx } = await setup(false) + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'context', args: { label: 'absent' }, uplink: toAsync([undefined, 'x']), + }))).resolves.toEqual([ + 'feed/context:feed:{"label":"absent"}:absent', + 'raw:undefined', + 'raw:"x"', + 'typert gateway: feed/context: invocation.uplink() is available once per call', + ]) + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'echo', args: { prefix: '' }, uplink: toAsync([undefined]), + }))).rejects.toMatchObject({ code: 'gateway/input-invalid', details: { endpoint: 'feed/echo', field: 'uplink' } }) + }) + it('releases the uplink when a unary method is opened through the stream carrier', async () => { const { ctx } = await setup(false) const returned = vi.fn(async (): Promise> => ({ value: undefined, done: true })) @@ -557,6 +572,13 @@ describe('Typert Remote streams', () => { }) }) + // An item frame without value is a top-level undefined item. + sendOpen(socket, 'absent', 'feed/context', { label: 'w' }) + socket.send(JSON.stringify({ type: 'item', streamId: 'absent' })) + await vi.waitFor(() => { + expect(frames).toContainEqual({ type: 'item', streamId: 'absent', value: 'raw:undefined' }) + }) + sendOpen(socket, 'late', 'feed/hold', {}) await vi.waitFor(() => { expect(frames).toContainEqual({ type: 'item', streamId: 'late', value: 'held' }) }) socket.send(JSON.stringify({ type: 'end', streamId: 'late' })) @@ -725,6 +747,44 @@ describe('Typert Remote streams', () => { expect(service.signals[1]?.aborted).toBe(true) }) + it('keeps uplink frames to a Gateway-owned stream out of the bounded inbox', async () => { + const { ctx } = await setup(true, { streamInboxBytes: 64 }) + const source = (signal: AbortSignal): AsyncIterable<{ event: string; args: readonly unknown[] }> => (async function *() { + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + })() + ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST) + + // An in-process carrier's uplink is returned as soon as the Gateway-owned stream opens. + const returned = vi.fn(async (): Promise> => ({ value: undefined, done: true })) + const idle: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ next: () => new Promise>(() => {}), return: returned }), + } + const events = await ctx.typertGateway.wireStream.open('$events', { args: {} }, idle, undefined, new AbortController().signal) + await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) + await events[Symbol.asyncIterator]().return?.() + + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { + headers: { cookie: browserCookie(ctx) }, + }) + await once(socket, 'open') + const frames: Record[] = [] + socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) + sendOpen(socket, 'events', '$events', {}) + await vi.waitFor(() => { expect(frames.filter(frame => frame.streamId === 'events')).toHaveLength(1) }) + for (let index = 0; index < 4; index += 1) { + socket.send(JSON.stringify({ type: 'item', streamId: 'events', value: 'x'.repeat(40) })) + } + // A Remote stream opened afterwards on the same socket shows the flood was processed and harmed nothing. + sendOpen(socket, 'after', 'feed/sync', { label: 'after' }) + await vi.waitFor(() => { expect(frames).toContainEqual({ type: 'end', streamId: 'after' }) }) + expect(frames.filter(frame => frame.streamId === 'events').map(frame => frame.type)).toEqual(['item']) + socket.close() + await once(socket, 'close') + }) + it('carries the registered Remote event source and withdraws its active stream', async () => { const { ctx } = await setup(true) let sourceSignal: AbortSignal | undefined diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index a5fedf67d4..ddd6a846b8 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -2683,6 +2683,66 @@ describe('Client Typert API', () => { } }) + it('sends a top-level undefined item as an item frame without value', async () => { + await withFakeWebSocket('https://harness.example/', async () => { + const ctx = await bench(vi.fn(), 'web') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const handle: RemoteStreamHandle = ctx.remote.probe.attach('alpha') + handle.send(undefined) + expect(() => { handle.send({ nested: undefined }) }).toThrow('is not a lossless JSON value') + const socket = FakeWebSocket.sockets[0]! + await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) }) + const opened = JSON.parse(socket.sent[0]!) as { streamId: string } + const item = JSON.parse(socket.sent[1]!) as Record + expect(item).toEqual({ type: 'item', streamId: opened.streamId }) + expect(Object.keys(item)).toEqual(['type', 'streamId']) + handle.dispose() + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + }) + + it('disposes on an iterator return that precedes the first read', async () => { + await withFakeWebSocket('https://harness.example/', async () => { + const ctx = await bench(vi.fn(), 'web') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const handle = ctx.remote.probe.attach('alpha') + const socket = FakeWebSocket.sockets[0]! + await vi.waitFor(() => { expect(socket.sent).toHaveLength(1) }) + await expect(handle[Symbol.asyncIterator]().return?.()).resolves.toEqual({ done: true, value: undefined }) + await vi.waitFor(() => { + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'cancel']) + }) + expect(() => { handle.send('late') }).toThrow('client api: probe/attach stream has terminated') + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + }) + + it('closes the uplink when the stream fails to open and keeps the failure for the reader', async () => { + const open: NonNullable = () => ({ + [Symbol.asyncIterator]: () => ({ next: () => Promise.reject(new Error('handshake failed')) }), + }) + const { ctx, client } = await benchFiber(vi.fn(), 'in-process', open) + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const handle = ctx.remote.probe.attach('alpha') + await vi.waitFor(() => { + expect(() => { handle.send('late') }).toThrow('client api: probe/attach stream has terminated') + }) + await expect(drainStream(handle)).rejects.toThrow('handshake failed') + } finally { + await dispose() + await client.dispose() + } + }) + it('refuses an uplink item that is not a lossless JSON value', async () => { const mock = RemoteMock.create().stream('probe/attach', async (args, stream) => { const [{ topic }] = args as [{ readonly topic: string }] diff --git a/packages/api/gateway/tests/remote-event-protocol.host.spec.ts b/packages/api/gateway/tests/remote-event-protocol.host.spec.ts index 09259ea43f..87dac480ce 100644 --- a/packages/api/gateway/tests/remote-event-protocol.host.spec.ts +++ b/packages/api/gateway/tests/remote-event-protocol.host.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' +import { isRemoteJsonValue } from '@deepseek-ai/dsh-typert-protocol' import { - isRemoteJsonValue, parseRemoteEventResult, parseRemoteStreamClientMessage, projectRemoteEventRequest, diff --git a/packages/experimental/webworker-runtime/src/client/client.ts b/packages/experimental/webworker-runtime/src/client/client.ts index 9b19a3895f..e6a2a18537 100644 --- a/packages/experimental/webworker-runtime/src/client/client.ts +++ b/packages/experimental/webworker-runtime/src/client/client.ts @@ -38,10 +38,16 @@ type LogicalStreamFrame = TunnelStreamItemFrame | TunnelStreamEndFrame | TunnelS interface UplinkPump { /** Settles once the pump has stopped posting frames. */ readonly done: Promise - /** Interrupt the pump, including a read blocked on the caller's iterator. */ + /** Interrupt the pump, including a read blocked on the caller's iterator, and return that iterator at once. */ stop(): void } +/** One open logical stream: its downlink inbox and, once the open frame is posted, its uplink pump. */ +interface LogicalStream { + readonly inbox: LogicalStreamInbox + pump: UplinkPump | undefined +} + interface TunnelStreamFailureMarker { readonly kind: 'remote' | 'carrier' readonly code?: string @@ -152,7 +158,7 @@ export class WorkerTunnel { private nextId = 1 private readonly unary = new Map() private readonly bodyStreams = new Map>() - private readonly logicalStreams = new Map() + private readonly logicalStreams = new Map() /** * In-flight request descriptions, so a refusal names what was refused. * @@ -187,7 +193,7 @@ export class WorkerTunnel { kind: 'carrier', message: `web-preview tunnel: worker failed: ${event.message}`, }, { cause: reason }) - for (const inbox of this.logicalStreams.values()) inbox.fail(failure) + for (const { inbox } of this.logicalStreams.values()) inbox.fail(failure) this.logicalStreams.clear() for (const release of this.releases.values()) release() this.releases.clear() @@ -250,12 +256,12 @@ export class WorkerTunnel { signal.throwIfAborted() const id = this.nextId++ const inbox = new LogicalStreamInbox() + const stream: LogicalStream = { inbox, pump: undefined } let opened = false let terminal = false - let pump: UplinkPump | undefined const onAbort = (): void => { inbox.fail(signal.reason) } signal.addEventListener('abort', onAbort, { once: true }) - this.logicalStreams.set(id, inbox) + this.logicalStreams.set(id, stream) this.inFlight.set(id, `STREAM ${endpoint}`) try { const frame: TunnelStreamOpenFrame = { t: 'stream-open', id, endpoint, payload } @@ -268,7 +274,7 @@ export class WorkerTunnel { message: `web-preview tunnel: failed to open Remote stream ${endpoint}`, }, { cause }) } - if (uplink !== undefined) pump = this.pumpUplink(id, uplink, signal, inbox) + if (uplink !== undefined) stream.pump = this.pumpUplink(id, uplink, signal, inbox) while (true) { const response = await inbox.next() signal.throwIfAborted() @@ -284,15 +290,19 @@ export class WorkerTunnel { signal.removeEventListener('abort', onAbort) this.logicalStreams.delete(id) this.inFlight.delete(id) - pump?.stop() + stream.pump?.stop() if (opened && !terminal) this.abortWorkerOperation(id) - if (pump !== undefined) await pump.done + if (stream.pump !== undefined) await stream.pump.done } } /** * Post the caller's uplink items for one logical stream. A failing uplink * fails the downlink, and the enclosing `open` then aborts the worker side. + * `stop()` interrupts a pump blocked on `uplink.next()` and returns the + * caller's iterator at once, so a handle's queue closes and `send()` throws + * from then on; a generator blocked in `next()` completes that return only + * once it yields, so it is not awaited. */ private pumpUplink( id: TunnelRequestId, @@ -301,40 +311,44 @@ export class WorkerTunnel { inbox: LogicalStreamInbox, ): UplinkPump { const interrupt = Promise.withResolvers>() - let active = true - const done = this - .forwardUplink(id, uplink[Symbol.asyncIterator](), interrupt.promise, () => active && !signal.aborted) - .catch((error: unknown) => { inbox.fail(error) }) + const iterator = uplink[Symbol.asyncIterator]() + const state = { active: true, released: false } + const release = (): void => { + if (state.released) return + state.released = true + void Promise.resolve(iterator.return?.()).catch(() => undefined) + } + const done = this.forwardUplink(id, iterator, interrupt.promise, () => state.active && !signal.aborted) + .then((exhausted) => { state.released ||= exhausted }, (error: unknown) => { inbox.fail(error) }) + .then(release) return { done, stop: () => { - active = false + state.active = false interrupt.resolve({ value: undefined, done: true }) + release() }, } } - /** Post items until the caller's iterator ends, the pump is stopped, or the page aborts the stream. */ + /** + * Post items until the caller's iterator ends, the pump is stopped, or the page aborts the stream. + * @returns whether the caller's iterator was exhausted and the uplink end posted. + */ private async forwardUplink( id: TunnelRequestId, iterator: AsyncIterator, interrupt: Promise>, isActive: () => boolean, - ): Promise { - let exhausted = false - try { - for (;;) { - const next = await Promise.race([iterator.next(), interrupt]) - if (!isActive()) return - if (next.done === true) break - this.worker.postMessage({ t: 'stream-uplink-item', id, value: next.value } satisfies TunnelStreamUplinkItemFrame) - } - exhausted = true - this.worker.postMessage({ t: 'stream-uplink-end', id } satisfies TunnelStreamUplinkEndFrame) - } finally { - // A generator blocked in next() completes this return only once it yields, so it is not awaited. - if (!exhausted) void Promise.resolve().then(() => iterator.return?.()).catch(() => undefined) + ): Promise { + for (;;) { + const next = await Promise.race([iterator.next(), interrupt]) + if (!isActive()) return false + if (next.done === true) break + this.worker.postMessage({ t: 'stream-uplink-item', id, value: next.value } satisfies TunnelStreamUplinkItemFrame) } + this.worker.postMessage({ t: 'stream-uplink-end', id } satisfies TunnelStreamUplinkEndFrame) + return true } /** @@ -533,7 +547,11 @@ export class WorkerTunnel { case 'stream-item': case 'stream-end': case 'stream-error': { - this.logicalStreams.get(frame.id)?.push(frame) + const stream = this.logicalStreams.get(frame.id) + if (stream === undefined) return + stream.inbox.push(frame) + // A terminal frame ends the uplink now, not on the consumer's next read. + if (frame.t !== 'stream-item') stream.pump?.stop() return } default: { diff --git a/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts b/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts index e0403834f9..2c5ae1545d 100644 --- a/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts +++ b/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts @@ -337,6 +337,27 @@ function stubWorker(): { check('the caller iterator is released without an uplink end', [released, sent.map(frame => frame.t)], [true, ['stream-open']]) } +// A terminal frame releases the caller's uplink iterator in the receive path, before the consumer reads it. +{ + const { worker, deliver } = stubWorker() + const tunnel = new WorkerTunnel(worker) + let released = false + const uplink: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + next: () => new Promise>(() => {}), + return: async (): Promise> => { + released = true + return { value: undefined, done: true } + }, + }), + } + const stream = tunnel.open('job/attach', {}, new AbortController().signal, uplink)[Symbol.asyncIterator]() + const ended = stream.next() + deliver({ t: 'stream-end', id: 1 }) + check('a terminal frame releases the uplink before the consumer reads', released, true) + check('the downlink then ends', await ended, { done: true, value: undefined }) +} + /** Wait, in small hops, for a condition that a concurrent pump settles. */ async function settled(condition: () => boolean): Promise { for (let attempt = 0; attempt < 200 && !condition(); attempt += 1) { diff --git a/packages/test-support/remote-mock/README.i18n.yaml b/packages/test-support/remote-mock/README.i18n.yaml index 68d36df291..63dbc05d7e 100644 --- a/packages/test-support/remote-mock/README.i18n.yaml +++ b/packages/test-support/remote-mock/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/test-support/remote-mock/README.md -README.md: f18a4b699e4e535d95fd82309ef0cce8f9aed1ed -README.zh.md: 0c49a5073d826f86cd33e03a27ddf74ba0f86ae1 +README.md: 7f76ce6217d7cc6f86923aa1ee4f7257ad30926e +README.zh.md: b79849fe3b23b5135feff7095468f65809b0d506 diff --git a/packages/test-support/remote-mock/README.md b/packages/test-support/remote-mock/README.md index f18a4b699e..7f76ce6217 100644 --- a/packages/test-support/remote-mock/README.md +++ b/packages/test-support/remote-mock/README.md @@ -73,7 +73,7 @@ mock.streams.fail('session/follow', new Error('gone')) await mock.streams.drained('session/follow') ``` -A failed stream rejects the consumer's next read with the given `Error`. Consumer cancellation (the opening signal or an early iterator `return()`) aborts `StreamHandle.signal`, ends the iteration without throwing, and logs the stream as `cancelled`. `StreamHandle.uplink` is what the script reads as the Client's uplink: through `rpc.open` it is the carrier's iterable; for a direct `mock.remote..(...)` call it is fed by the returned handle, which is the `RemoteStreamHandle` a generated method returns (iterate it for the downlink, `send()` and `end()` feed the uplink, `dispose()` cancels the stream). The mock function is called with the method's own arguments only, so the uplink never appears in call assertions or logged args. +A failed stream rejects the consumer's next read with the given `Error`. Consumer cancellation (the opening signal or an early iterator `return()`) aborts `StreamHandle.signal`, ends the iteration without throwing, and logs the stream as `cancelled`. `StreamHandle.uplink` is what the script reads as the Client's uplink: through `rpc.open` it is the carrier's iterable; for a direct `mock.remote..(...)` call it is fed by the returned handle, which is the `RemoteStreamHandle` a generated method returns (iterate it for the downlink, `send()` and `end()` feed the uplink, `dispose()` cancels the stream); `send()` rejects a non-lossless JSON item as the real handle does, and the uplink it owns closes once the stream settles, is cancelled, or the consumer leaves, so a later `send()` throws. The mock function is called with the method's own arguments only, so the uplink never appears in call assertions or logged args. A fake that stands in for a generated stream method returns the `RemoteStreamHandle` the generated method does. `streamHandle(source)` types an `AsyncIterable` as that handle with inert `send`, `end`, and `dispose`; `streamMethod(generator)` lifts an async generator function written for the method's arguments into the method's own signature, for `vi.fn()` and `mockImplementation`. @@ -132,7 +132,7 @@ None; this package neither assembles nor sends a provider request. - **In-process carrier only** — `rpc` serves a Connection instance in the same realm; no HTTP or WebSocket carrier for browser-lane specs is provided. -- **Values cross by reference** — answers and stream items reach the client unserialized, so a non-JSON value that the real wire would reject passes through unchanged. +- **Values cross by reference** — answers and downlink items reach the client unserialized, so a non-JSON downlink value that the real wire would reject passes through unchanged; only the handle's `send()` applies the real uplink check. - **Values are not checked** — a unary answer must be the result the caller reads (`{ ok, value }` or `{ ok: false, error }`); the mock passes it through unchanged and does not check those fields. - **No payload matching** — rules match on endpoint only; discriminate on business arguments inside a handler. - **Native stream overrides own their iterable** — an override returning its own iterable bypasses scripted-stream logs, `requests`, `opened`, `drained`, and `push` / `end` / `fail`; the caller also owns cancellation. Native call assertions still work. Use a registered stream script when a scenario needs those controls. diff --git a/packages/test-support/remote-mock/README.zh.md b/packages/test-support/remote-mock/README.zh.md index 0c49a5073d..b79849fe3b 100644 --- a/packages/test-support/remote-mock/README.zh.md +++ b/packages/test-support/remote-mock/README.zh.md @@ -73,7 +73,7 @@ mock.streams.fail('session/follow', new Error('gone')) await mock.streams.drained('session/follow') ``` -失败的流让消费方的下一次读取以给定的 `Error` reject。消费方取消(打开时的 signal 或 iterator 提前 `return()`)会中止 `StreamHandle.signal`、结束迭代而不抛错,并把该流记为 `cancelled`。`StreamHandle.uplink` 是脚本读到的 Client 上行:经 `rpc.open` 打开时它是载体传入的 iterable;直接调用 `mock.remote..(...)` 时它由返回的句柄喂入——该句柄就是生成方法返回的 `RemoteStreamHandle`(迭代它读下行,`send()` 与 `end()` 喂上行,`dispose()` 取消流)。mock 函数只以方法自己的参数被调用,上行不会出现在调用断言或记录的 args 里。 +失败的流让消费方的下一次读取以给定的 `Error` reject。消费方取消(打开时的 signal 或 iterator 提前 `return()`)会中止 `StreamHandle.signal`、结束迭代而不抛错,并把该流记为 `cancelled`。`StreamHandle.uplink` 是脚本读到的 Client 上行:经 `rpc.open` 打开时它是载体传入的 iterable;直接调用 `mock.remote..(...)` 时它由返回的句柄喂入——该句柄就是生成方法返回的 `RemoteStreamHandle`(迭代它读下行,`send()` 与 `end()` 喂上行,`dispose()` 取消流);`send()` 像真实句柄一样拒绝非无损 JSON 项,它拥有的上行在流结束、取消或消费方离开时关闭,之后的 `send()` 抛错。mock 函数只以方法自己的参数被调用,上行不会出现在调用断言或记录的 args 里。 替代生成流方法的假实现返回生成方法所返回的 `RemoteStreamHandle`。`streamHandle(source)` 把一个 `AsyncIterable` 标注为该句柄,其 `send`、`end`、`dispose` 为空操作;`streamMethod(generator)` 把按方法参数编写的 async generator 函数提升为方法自己的签名,供 `vi.fn()` 与 `mockImplementation` 使用。 @@ -132,7 +132,7 @@ await mock.streams.drained('session/follow') - **仅进程内载体**——`rpc` 服务同一 realm 中的 Connection 实例;不提供给浏览器车道测试用的 HTTP 或 WebSocket 载体。 -- **值按引用传递**——应答与流项都未经序列化就到达客户端,真实线路会拒绝的非 JSON 值在这里原样通过。 +- **值按引用传递**——应答与下行项都未经序列化就到达客户端,真实线路会拒绝的非 JSON 下行值在这里原样通过;只有句柄的 `send()` 施加真实的上行校验。 - **不校验值**——一元应答必须是调用方读取的结果(`{ ok, value }` 或 `{ ok: false, error }`);mock 原样传递它,不检查这些字段。 - **不做 payload 匹配**——规则只按端点匹配;在 handler 内按业务参数判别。 - **原生流覆盖自行管理 iterable**——覆盖返回自有 iterable 时,不参与脚本流日志、`requests`、`opened`、`drained` 以及 `push` / `end` / `fail`;调用方也负责取消。原生调用断言仍然有效。需要这些控制能力的场景应使用已登记的流脚本。 diff --git a/packages/test-support/remote-mock/package.json b/packages/test-support/remote-mock/package.json index c9b683d880..af878fcb05 100644 --- a/packages/test-support/remote-mock/package.json +++ b/packages/test-support/remote-mock/package.json @@ -26,7 +26,8 @@ "@vitest/spy": "^4.1.8" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/test-support/remote-mock/src/remote-mock.ts b/packages/test-support/remote-mock/src/remote-mock.ts index 39d29d5e35..e35ffdb2e2 100644 --- a/packages/test-support/remote-mock/src/remote-mock.ts +++ b/packages/test-support/remote-mock/src/remote-mock.ts @@ -337,7 +337,7 @@ export class RemoteMock { } else { uplink = carrierUplink } - const stream = new MockStream(this.logStore.stream(endpoint, args), signal, uplink) + const stream = new MockStream(this.logStore.stream(endpoint, args), signal, uplink, () => { owned?.close() }) this.live.push(stream) this.wakeOpened(endpoint) stream.run(script, args) diff --git a/packages/test-support/remote-mock/src/streams.ts b/packages/test-support/remote-mock/src/streams.ts index 6359cfdfe9..5c2093f136 100644 --- a/packages/test-support/remote-mock/src/streams.ts +++ b/packages/test-support/remote-mock/src/streams.ts @@ -1,6 +1,6 @@ /** Stream scripts and the pushable, abort-aware stream a script drives. */ -import type { RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol' +import { isRemoteUplinkItem, type RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol' import type { StreamRecord } from './log.ts' /** @@ -106,11 +106,13 @@ export class MockStream implements StreamHandle, AsyncIterable { * @param record - log entry this stream updates. * @param sourceSignal - cancellation from the caller that opened the stream. * @param uplink - the uplink the script reads. + * @param release - closes the uplink this stream owns; called once, when the stream settles, is cancelled, or the consumer leaves. */ constructor( readonly record: StreamRecord, private readonly sourceSignal: AbortSignal, readonly uplink: AsyncIterable, + private readonly release: () => void, ) { if (sourceSignal.aborted) this.cancel(sourceSignal.reason) else sourceSignal.addEventListener('abort', this.onAbort, { once: true }) @@ -200,6 +202,7 @@ export class MockStream implements StreamHandle, AsyncIterable { this.record.state = state this.settled = settled this.sourceSignal.removeEventListener('abort', this.onAbort) + this.release() this.wakeDrained() // A pending read exists only while the queue is empty: push() resolves it directly instead of queueing. const waiting = this.waiting @@ -216,6 +219,7 @@ export class MockStream implements StreamHandle, AsyncIterable { this.waiting = undefined this.queue.length = 0 this.cancellation.abort(reason) + this.release() waiting?.resolve({ value: undefined, done: true }) } // Whether cancelled or left after the producer settled, the consumer pulls nothing more: drop what it left. @@ -236,9 +240,9 @@ export class MockStream implements StreamHandle, AsyncIterable { } /** - * The uplink a direct handle owns: `send()` pushes, `end()` half-closes, - * `close()` ends it when the handle is disposed. The script reads it as - * `StreamHandle.uplink`. + * The uplink a direct handle owns: `send()` pushes, `end()` half-closes, and + * `close()` ends it and drops unread items once the stream settles, is + * cancelled, or the consumer leaves. The script reads it as `StreamHandle.uplink`. */ export class HandleUplink implements AsyncIterable { private readonly items: unknown[] = [] @@ -266,7 +270,7 @@ export class HandleUplink implements AsyncIterable { this.waiter = undefined } - /** Ends the uplink and drops its unread items; `dispose()` on the handle and `return()` on the script's iterator call it. */ + /** Ends the uplink and drops unread items; the owning stream calls it on settle or cancel, the script's iterator on `return()`. */ close(): void { this.items.length = 0 this.end() @@ -290,8 +294,10 @@ export class HandleUplink implements AsyncIterable { /** * The handle a direct `mock.remote` stream call returns, as a generated method * would: iterate it for the script's downlink, `send()` and `end()` feed - * `StreamHandle.uplink`, `dispose()` cancels the stream. A stream opened through - * `rpc.open` reads the carrier's uplink instead, and this handle owns none. + * `StreamHandle.uplink`, `dispose()` cancels the stream. `send()` applies the + * real handle's check, so a non-lossless JSON item throws and a top-level + * `undefined` passes. A stream opened through `rpc.open` reads the carrier's + * uplink instead, and this handle owns none. */ export class MockClientStream implements RemoteStreamHandle { /** @@ -304,6 +310,9 @@ export class MockClientStream implements RemoteStreamHandle { if (this.uplink === undefined) { throw new Error(`remote-mock: ${this.stream.record.endpoint} uplink belongs to the carrier that opened the stream`) } + if (!isRemoteUplinkItem(item)) { + throw new Error(`remote-mock: ${this.stream.record.endpoint} uplink item is not a lossless JSON value`) + } this.uplink.push(item) } @@ -312,7 +321,6 @@ export class MockClientStream implements RemoteStreamHandle { } dispose(): void { - this.uplink?.close() this.stream.dispose() } diff --git a/packages/test-support/remote-mock/tests/rpc.client.spec.ts b/packages/test-support/remote-mock/tests/rpc.client.spec.ts index fd83e0ecf1..772a5fa6d8 100644 --- a/packages/test-support/remote-mock/tests/rpc.client.spec.ts +++ b/packages/test-support/remote-mock/tests/rpc.client.spec.ts @@ -66,6 +66,41 @@ describe('RemoteMock.rpc', () => { expect(() => { direct.send('late') }).toThrow('remote-mock: uplink was ended') }) + it('applies the real handle checks to send and closes the owned uplink with the stream', async () => { + const mock = RemoteMock.create() + .stream('job/attach', async (args, stream) => { + for await (const item of stream.uplink) stream.push(`${String(args[0])}:${String(item)}`) + stream.end() + }) + .stream('job/quiet', (args, stream) => { + stream.push(String(args[0])) + stream.end() + }) + const absent = mock.open('job/attach', ['job-7'], idle()) as MockClientStream + absent.send(undefined) + expect(() => { absent.send(1n) }).toThrow('remote-mock: job/attach uplink item is not a lossless JSON value') + absent.end() + await expect(drain(absent)).resolves.toEqual(['job-7:undefined']) + // A script that ends the downlink without reading the uplink closes the owned uplink. + const quiet = mock.open('job/quiet', ['job-8'], idle()) as MockClientStream + await expect(drain(quiet)).resolves.toEqual(['job-8']) + expect(() => { quiet.send('late') }).toThrow('remote-mock: uplink was ended') + // A consumer that leaves early closes it too, and a script blocked on the uplink wakes. + const unblocked = Promise.withResolvers() + const blocked = RemoteMock.create().stream('job/attach', async (args, stream) => { + stream.push(`${String(args[0])}:first`) + for await (const item of stream.uplink) stream.push(String(item)) + unblocked.resolve(undefined) + }) + const left = blocked.open('job/attach', ['job-9'], idle()) as MockClientStream + for await (const item of left) { + expect(item).toBe('job-9:first') + break + } + await unblocked.promise + expect(() => { left.send('late') }).toThrow('remote-mock: uplink was ended') + }) + it('rejects malformed payloads and unmatched endpoints, logging the miss', async () => { const mock = RemoteMock.create() await expect(mock.rpc.call('/api', 'a/b', 'bare')).rejects.toThrow('remote-mock: payload of a/b must be { args: unknown[] | object }') diff --git a/packages/typert/protocol/README.i18n.yaml b/packages/typert/protocol/README.i18n.yaml index 679a8d3e38..8a68e37489 100644 --- a/packages/typert/protocol/README.i18n.yaml +++ b/packages/typert/protocol/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/typert/protocol/README.md -README.md: fcf9490bb0cece7cdb1264c98de33d22ba52b13c -README.zh.md: 92fa38cda553d668a8898ee4345faea04879eded +README.md: e96c53fb2c8ad671fce1bb25c235c06517fc3d6b +README.zh.md: e9cb0f8496683c12ea009530a6cf97bdd023ee24 diff --git a/packages/typert/protocol/README.md b/packages/typert/protocol/README.md index fcf9490bb0..e96c53fb2c 100644 --- a/packages/typert/protocol/README.md +++ b/packages/typert/protocol/README.md @@ -44,7 +44,7 @@ export class GoalService extends TypertRemoteService { Generation turns the method into a wire endpoint under the service's namespace; Clients call it as a typed method through `ctx.remote` (see the [API Gateway reference](../../../docs/api-gateway.md)). A method opts into cooperative cancellation by declaring `signal: AbortSignal` as its final parameter — the signal is injected, never a JSON parameter or lookup field. -A stream method (`@Remote({ mode: 'stream' })`) returns `Iterable`, `AsyncIterable`, or `RemoteStream`. `In` declares the items the Client may send back on the same logical stream; the method reads them through `this.ctx.invocation.uplink()`, and the descriptor carries their codec. `RemoteInvocation` also names the receiving `service`, the calling `peer` (a `PeerScope` the connection layer admitted), and the carrier `signal`; `ctx.invocation` is `undefined` on a Context no Remote call derived: the first `TypertRemoteService` constructed in a tree registers that accessor on the root. A generated Client stream method returns `RemoteStreamHandle`: the handle with `send`, `end`, and `dispose` beside the downlink iteration. +A stream method (`@Remote({ mode: 'stream' })`) returns `Iterable`, `AsyncIterable`, or `RemoteStream`. `In` declares the items the Client may send back on the same logical stream; the method reads them through `this.ctx.invocation.uplink()`, and the descriptor carries their codec. `RemoteInvocation` also names the receiving `service`, the calling `peer` (a `PeerScope` the connection layer admitted), and the carrier `signal`; `ctx.invocation` is `undefined` on a Context no Remote call derived: the first `bindTypertRemote()` binding in a tree, which every `TypertRemoteService` constructor makes, registers that accessor on the root. A generated Client stream method returns `RemoteStreamHandle`: the handle with `send`, `end`, and `dispose` beside the downlink iteration. Uplink items are validated one by one at the Host because they arrive from the browser; downlink items are values the Host method produced and pass through. ### Associating Host objects and Contexts with wire identities @@ -102,6 +102,7 @@ Every namespace, method, lookup, and Context segment must satisfy `isTypertRemot | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Decorators, Gateway bindings, `remoteMethods`, segment validation | +| [`src/json-value.ts`](src/json-value.ts) | `isRemoteJsonValue` and `isRemoteUplinkItem`, the lossless JSON checks every carrier shares | | [`src/remote-error.ts`](src/remote-error.ts) | `RemoteError` and the structural `remoteErrorOf` recognizer | | [`src/types.ts`](src/types.ts) | Protocol maps, `RemoteErrorDetailsMap`, `RemoteResult`, `RemoteStream`, `RemoteStreamHandle`, `PeerScope`, `RemoteInvocation`, `InvocationDescriptor`, codecs, provider contracts, registry interfaces, `TypertClientRemote` | | — | No runtime invariant companion is published; decorators retain private immutable declarations and bindings are frozen values with no independent event stream to cross-check. | diff --git a/packages/typert/protocol/README.zh.md b/packages/typert/protocol/README.zh.md index 92fa38cda5..e9cb0f8496 100644 --- a/packages/typert/protocol/README.zh.md +++ b/packages/typert/protocol/README.zh.md @@ -44,7 +44,7 @@ export class GoalService extends TypertRemoteService { 生成会把方法变为服务命名空间下的 wire 端点;Client 通过 `ctx.remote` 以类型化方法调用它(见 [API Gateway 参考](../../../docs/api-gateway.zh.md))。方法把 `signal: AbortSignal` 声明为最后一个参数即可选择协作式取消——该信号是注入的,绝不会成为 JSON 参数或查找字段。 -流方法(`@Remote({ mode: 'stream' })`)返回 `Iterable`、`AsyncIterable` 或 `RemoteStream`。`In` 声明 Client 可以在同一条逻辑流上回送的项;方法通过 `this.ctx.invocation.uplink()` 读取它们,描述符携带其 codec。`RemoteInvocation` 还给出接收服务 `service`、发起调用的 `peer`(连接层接纳的一个 `PeerScope`)与载体 `signal`;非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`:树中第一个被构造的 `TypertRemoteService` 在根上注册该 accessor。生成的 Client 流方法返回 `RemoteStreamHandle`:在下行迭代之外提供 `send`、`end` 与 `dispose` 的句柄。 +流方法(`@Remote({ mode: 'stream' })`)返回 `Iterable`、`AsyncIterable` 或 `RemoteStream`。`In` 声明 Client 可以在同一条逻辑流上回送的项;方法通过 `this.ctx.invocation.uplink()` 读取它们,描述符携带其 codec。`RemoteInvocation` 还给出接收服务 `service`、发起调用的 `peer`(连接层接纳的一个 `PeerScope`)与载体 `signal`;非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`:树中第一个 `bindTypertRemote()` 绑定(每个 `TypertRemoteService` 构造函数都会建立一个)在根上注册该 accessor。生成的 Client 流方法返回 `RemoteStreamHandle`:在下行迭代之外提供 `send`、`end` 与 `dispose` 的句柄。上行项在 Host 逐项校验,因为它们来自浏览器;下行项是 Host 方法产出的值,原样透传。 ### 把 Host 对象与 Context 关联到 wire identity @@ -102,6 +102,7 @@ Host 装配以转发给消费方的 Cordis 事件扩展 `TypertRemoteEventSelect | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 装饰器、Gateway 绑定、`remoteMethods`、段校验 | +| [`src/json-value.ts`](src/json-value.ts) | 各载体共享的无损 JSON 校验 `isRemoteJsonValue` 与 `isRemoteUplinkItem` | | [`src/remote-error.ts`](src/remote-error.ts) | `RemoteError` 与结构式识别函数 `remoteErrorOf` | | [`src/types.ts`](src/types.ts) | 协议映射、`RemoteErrorDetailsMap`、`RemoteResult`、`RemoteStream`、`RemoteStreamHandle`、`PeerScope`、`RemoteInvocation`、`InvocationDescriptor`、编解码器、提供方约定、注册表接口、`TypertClientRemote` | | — | 不发布运行时不变量伴生入口;decorator 只保留私有不可变声明,binding 也是冻结值,没有可供交叉核对的独立事件流。 | diff --git a/packages/typert/protocol/src/index.ts b/packages/typert/protocol/src/index.ts index 8b18229cb4..f94096beb3 100644 --- a/packages/typert/protocol/src/index.ts +++ b/packages/typert/protocol/src/index.ts @@ -5,12 +5,13 @@ * @module @deepseek-ai/dsh-typert-protocol */ -import { Service, type Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { TypertContextMap } from './types.ts' export { RemoteError, remoteErrorOf } from './remote-error.ts' export { TYPERT_OWNED_VALUE, isTypertOwnedValue, typertOwnedValue } from './owned-value.ts' export type { TypertOwnedValue } from './owned-value.ts' +export { isRemoteJsonValue, isRemoteUplinkItem } from './json-value.ts' const TYPERT_REMOTE_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ @@ -139,7 +140,10 @@ interface RemoteMethodDescriptorV1 { const REMOTE_METHOD_DESCRIPTOR = '@deepseek-ai/dsh-typert-protocol/remote-methods' /** - * Bind one visible Service field to a Cordis key and Remote namespace. + * Bind one visible Service field to a Cordis key and Remote namespace. A + * service that owns a Cordis Context also gives its tree `ctx.invocation`, + * `undefined` outside a Remote call, so no `TypertRemoteService` is needed for + * a Host composition to read it. * @param service - owning Service instance, normally `this`. * @param serviceKey - exact Cordis service key. * @param options - optional distinct wire namespace. @@ -153,6 +157,8 @@ export function bindTypertRemote( validateName('service key', serviceKey) const namespace = options.namespace ?? serviceKey validateName('namespace', namespace) + const ctx: unknown = Reflect.get(service, 'ctx') + if (ctx instanceof Context) provideInvocationAccessor(ctx) return Object.freeze({ service, serviceKey, namespace }) } @@ -170,7 +176,6 @@ export abstract class TypertRemoteService extends Service { protected constructor(ctx: Context, serviceKey: string, options: TypertGatewayBindingOptions = {}) { super(ctx, serviceKey) this.typertRemote = bindTypertRemote(this, this.name, options) - provideInvocationAccessor(ctx) } } diff --git a/packages/typert/protocol/src/json-value.ts b/packages/typert/protocol/src/json-value.ts new file mode 100644 index 0000000000..323cc31ea0 --- /dev/null +++ b/packages/typert/protocol/src/json-value.ts @@ -0,0 +1,53 @@ +/** + * Lossless JSON checks every Remote carrier shares: the Client handle before it + * queues an uplink item, the Gateway at its wire and codec-less uplink + * boundaries, and the in-process mock. + */ + +/** + * Test whether a value crosses JSON transport without coercion or omission. + * @param value - candidate boundary value. + * @returns whether the value is losslessly JSON-compatible. + */ +export function isRemoteJsonValue(value: unknown): boolean { + return visitJsonValue(value, new Set()) +} + +/** + * Test whether a value may travel as one uplink item: a lossless JSON value, or + * a top-level `undefined`, which the wire carries as an `item` frame without + * `value`. Nested `undefined`, `NaN`, and infinities stay rejected. + * @param value - candidate uplink item. + * @returns whether the item crosses every carrier unchanged. + */ +export function isRemoteUplinkItem(value: unknown): boolean { + return value === undefined || isRemoteJsonValue(value) +} + +function visitJsonValue(value: unknown, ancestors: Set): boolean { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return true + if (typeof value === 'number') return Number.isFinite(value) && !Object.is(value, -0) + if (typeof value !== 'object') return false + if (ancestors.has(value)) return false + ancestors.add(value) + try { + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype + || Reflect.ownKeys(value).length !== value.length + 1) return false + for (let index = 0; index < value.length; index++) { + if (!Object.hasOwn(value, index) || !visitJsonValue(value[index], ancestors)) return false + } + return true + } + const prototype: unknown = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) return false + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string') return false + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (descriptor?.enumerable !== true || !visitJsonValue(Reflect.get(value, key), ancestors)) return false + } + return true + } finally { + ancestors.delete(value) + } +} diff --git a/packages/typert/protocol/src/types.ts b/packages/typert/protocol/src/types.ts index 4102fbe0a3..14917682b7 100644 --- a/packages/typert/protocol/src/types.ts +++ b/packages/typert/protocol/src/types.ts @@ -95,18 +95,22 @@ export type RemoteStream = AsyncIterable & { readonly [STR /** * One open Remote stream as the Client holds it: the downlink items as an * `AsyncIterable`, plus the uplink and cancellation of the same logical - * stream. A generated Client stream method returns it. A handle stands for - * one generation: when the carrier is lost, iteration fails with the carrier - * error and the handle is finished. + * stream. A generated Client stream method returns it, and calling that + * method opens the stream: a holder that neither iterates nor disposes the + * handle keeps the Host stream alive. A handle stands for one generation: + * when the carrier is lost, iteration fails with the carrier error and the + * handle is finished. * @template Out - item type the Host method yields. * @template In - item type the Client may send; `never` when the method reads none. */ export interface RemoteStreamHandle extends AsyncIterable { /** * Send one uplink item. Items sent before the stream has opened are queued - * and sent once the `open` frame is on the wire. + * and sent once the `open` frame is on the wire. A top-level `undefined` + * travels as an `item` frame without `value`. * @param item - item the Host validates against the method's uplink codec. - * @throws {Error} when `end()` was called or the stream has terminated. + * @throws {Error} when the item is not a lossless JSON value, when `end()` + * was called, or once the stream has terminated. */ send(item: In): void /** Half-close the uplink: the Host's `uplink()` iteration ends. Idempotent; ignored after termination. */ @@ -309,7 +313,7 @@ export interface InvocationDescriptor { readonly method: string /** Service member invoked when the exported method name is an alias. */ readonly implementation?: string - /** Absent for unary calls; stream calls validate and deliver every yielded item. */ + /** Absent for unary calls; stream calls deliver every yielded item as the Host produced it. */ readonly mode?: 'stream' /** Receiver selection mode. */ readonly invocation: diff --git a/packages/typert/protocol/tests/protocol.spec.ts b/packages/typert/protocol/tests/protocol.spec.ts index a8c4049046..e8e42b0ac0 100644 --- a/packages/typert/protocol/tests/protocol.spec.ts +++ b/packages/typert/protocol/tests/protocol.spec.ts @@ -1,6 +1,6 @@ import { execFileSync } from 'node:child_process' import { fileURLToPath } from 'node:url' -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { describe, expect, expectTypeOf, it } from 'vitest' import { bindTypertRemote, @@ -281,6 +281,22 @@ describe('typert-protocol Remote declarations', () => { expect(child.ctx.invocation).toBeUndefined() }) + it('provides the invocation accessor for a plain Service bound with bindTypertRemote', async () => { + const root = new Context() + expect(Object.hasOwn(root.reflect.props, 'invocation')).toBe(false) + class Plain extends Service { + readonly typertRemote = bindTypertRemote(this, 'plain') + + constructor(ctx: Context) { + super(ctx, 'plain') + } + } + await root.plugin(Plain) + expect(Object.hasOwn(root.reflect.props, 'invocation')).toBe(true) + expect(root.invocation).toBeUndefined() + expect((root.get('plain') as Plain).typertRemote.namespace).toBe('plain') + }) + it('rejects malformed decorator calls and targets', () => { const method: (this: object) => void = function (this: object): void {} expect(() => { (Remote as unknown as (value: typeof method) => void)(method) }).toThrow('context is missing') From 5aa0e3f8061c05af5f616143edb5a22e59a9d5d7 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 20 Sep 2026 03:00:57 -0700 Subject: [PATCH 32/60] test(web): align developer tools check with preset copy --- apps/web/tests/developer-tools-settings.e2e.ts | 2 +- packages/client/ui-agent-preset/README.i18n.yaml | 4 ++-- packages/client/ui-agent-preset/README.md | 2 +- packages/client/ui-agent-preset/README.zh.md | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/tests/developer-tools-settings.e2e.ts b/apps/web/tests/developer-tools-settings.e2e.ts index 28dddd3c02..889db40d16 100644 --- a/apps/web/tests/developer-tools-settings.e2e.ts +++ b/apps/web/tests/developer-tools-settings.e2e.ts @@ -25,7 +25,7 @@ it('persists developer tools in the Host settings document and restores the acce await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false') await page.getByRole('button', { name: 'Agent presets', exact: true }).click() - const pickerToggle = page.getByRole('switch', { name: 'Allow switching Agent modes' }) + const pickerToggle = page.getByRole('switch', { name: 'Choose a mode for new tasks' }) await expect.poll(() => page.getByRole('heading', { name: 'Agent presets', exact: true }).count()).toBe(1) expect(await pickerToggle.count()).toBe(0) await scaffold.ctx.settings.update('ui-developer-tools', { enabled: true }) diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index f4b6a9b41d..de88b1368c 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/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-agent-preset/README.md -README.md: e1fb0bff3c35255c610347a8d50c7ff642cad918 -README.zh.md: 9b462d42f315c8dc9b78949de28df43cc0e5bad0 +README.md: f2c24e0b406c8c53bd49a1c72050f2f9c72c2300 +README.zh.md: 1b728834a35dcd756e9a044a4fb8274b7ea4c2ad diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index e1fb0bff3c..f2c24e0b40 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -25,7 +25,7 @@ Use this package to choose the agent preset for a new Web GUI session, see the a ## Use this package -The new-session picker and the management section’s “Allow switching Agent modes” row are visible only while Developer tools is enabled in General Settings. This presentation preference does not alter the selected preset, saved default, Host composition, or the management section’s independent picker policy. +The new-session picker and the management section’s “Choose a mode for new tasks” row are visible only while Developer tools is enabled in General Settings. This presentation preference does not alter the selected preset, saved default, Host composition, or the management section’s independent picker policy. Mount this plugin alongside the settings and conversation packages; once Developer tools is enabled, the management section shows a visibility switch that is on by default. While it is off, the new-session chip is absent and the Host composes an unnamed session from the deployment default (`standard` in the shipped Web bundle). Turning it on restores the saved user default, or uses the deployment default when none has been saved, and carries that default to the current blank task; a chip pick itself is staged only once for the next blank session. Turning the picker off again returns the current blank task to the deployment default the same way and discards an unconsumed stage; started and historical sessions keep their labels, compositions, and recorded history. diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 9b462d42f3..1b728834a3 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -新会话选择器和管理区域中的「允许切换agent模式」整行设置仅在通用设置中开启开发者工具时显示。此展示偏好不会改变已选预设、保存的默认值、Host 组合或管理区域独立的选择器策略。 +新会话选择器和管理区域中的「新任务可选择模式」整行设置仅在通用设置中开启开发者工具时显示。此展示偏好不会改变已选预设、保存的默认值、Host 组合或管理区域独立的选择器策略。 与设置和对话包一起挂载本插件;通用设置开启开发者工具后,管理分区会显示一个默认开启的可见性开关。关闭期间,新建会话 chip 不出现,宿主会依据部署默认值(随附 Web bundle 中为 `standard`)组装未指名会话。开启时会恢复已保存的用户默认值;尚未保存时则使用部署默认值;该默认值会同时带到当前空白任务上。chip 中的选择本身只为下一个空白会话暂存一次。再次关闭选择器会以同样方式把当前空白任务带回部署默认值,并丢弃尚未使用的暂存选择;已开始及历史会话的标签、组装与已记录历史均保持不变。 From d620c1e4b769d98ce06a9ee6de72df3ea24184c0 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 20 Sep 2026 03:08:34 -0700 Subject: [PATCH 33/60] fix(web): soften segmented tab focus styling --- packages/client/ui-primitives/src/SegmentedTabs.module.css | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-primitives/src/SegmentedTabs.module.css b/packages/client/ui-primitives/src/SegmentedTabs.module.css index 566e67c5e8..b16b597212 100644 --- a/packages/client/ui-primitives/src/SegmentedTabs.module.css +++ b/packages/client/ui-primitives/src/SegmentedTabs.module.css @@ -41,8 +41,10 @@ } .tab:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); - outline-offset: -1px; + outline: none; + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 4px; } @media (prefers-reduced-motion: reduce) { From 21e315a7ddc8ad57d965b9b79b0837c374d5ff51 Mon Sep 17 00:00:00 2001 From: winewill Date: Sun, 20 Sep 2026 18:18:13 +0800 Subject: [PATCH 34/60] docs(client): record the shell's Current version row in ui-settings-general The shell registers two General rows after #4716, but README.md and README.zh.md still described only Developer tools in three places each. Align both sides and re-record the bilingual pairing hashes. Also close the review nits: assert the settings.general.item rows dispose on unload, give the localized-label cases distinct titles (%s printed the dictionary object, so both read "[object Object]"), complete the CurrentVersionRow @param, and note why current-version orders last. --- packages/client/ui-settings-general/README.i18n.yaml | 4 ++-- packages/client/ui-settings-general/README.md | 6 +++--- packages/client/ui-settings-general/README.zh.md | 6 +++--- .../ui-settings-general/src/client/CurrentVersionRow.tsx | 2 +- packages/client/ui-settings-general/src/client/index.ts | 1 + .../client/ui-settings-general/tests/apply.client.spec.ts | 1 + .../ui-settings-general/tests/components.client.spec.tsx | 6 +++--- 7 files changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 37adfcdced..c5b1574510 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/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-settings-general/README.md -README.md: 030f9636b6e25d5cccd3ee82ab0df2fa85436ebd -README.zh.md: 36525dda0a68e97b15589d4513205075e0677c8b +README.md: e06ecf5f36234f634cbff708f592fbe83c9f175e +README.zh.md: 6eb54b49a7a7f1a55a6f9389c57f736533715185 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 030f9636b6..e06ecf5f36 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -37,7 +37,7 @@ The current release version appears at the bottom of General Settings in Web and The Developer tools switch controls the shared preference described by [ui-settings](../ui-settings/README.md#use-this-package). It is available in both Web and desktop, follows accepted changes immediately, and disables duplicate input while a write settles. A failed write displays localized retry guidance. -The General section holds the built-in Developer tools row and rows registered into `settings.general.item` by feature packages. Each registrant owns its row copy and behavior. The Appearance row, for example, lives in ui-theme. +The General section holds the built-in Developer tools and Current version rows alongside rows registered into `settings.general.item` by feature packages. Each registrant owns its row copy and behavior. The Appearance row, for example, lives in ui-theme. ### Opening the configuration file @@ -55,7 +55,7 @@ The onboarding ledger projects in ascending order and mounts exactly one step at
    Implementation internals — click to expand -The shell owns the chrome and the projections; it contributes the Developer tools row, while feature registrants own their additional content and copy. +The shell owns the chrome and the projections; it contributes the Developer tools and Current version rows, while feature registrants own their additional content and copy. ### Ledger projections @@ -106,7 +106,7 @@ None; this package neither assembles nor sends a provider request. These limits define what the shell itself provides versus what features must supply; they are current package constraints. -- **Additional General rows require their feature plugins** — the shell supplies Developer tools; feature plugins supply the remaining preferences. +- **Additional General rows require their feature plugins** — the shell supplies Developer tools and Current version; feature plugins supply the remaining preferences. - **The Windows caption badge keeps a side-opening bubble** — `DesktopUpdateBadge` occupies `sidebar.toggle.badge` in the caption and requests `side="right"`, so the Desktop-owned menu text can cover its bubble while the sidebar is collapsed on Windows; the sidebar toggle and New Session bubbles open below the caption instead (#4688). diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 36525dda0a..6eb54b49a7 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -37,7 +37,7 @@ Web 与桌面端的通用设置底部显示当前发布版本,使用构建注 开发者工具开关控制 [ui-settings](../ui-settings/README.zh.md#use-this-package) 定义的共享偏好。Web 和桌面端均提供此开关,立即跟随已接受的变更,并在写入完成前禁用重复输入。写入失败时显示本地化的重试提示。 -「通用」分区承载内置的开发者工具行和功能包注册进 `settings.general.item` 的行。每个注册方拥有自己的行文案与行为。例如「外观」行位于 ui-theme。 +「通用」分区承载内置的开发者工具行与当前版本行,以及功能包注册进 `settings.general.item` 的行。每个注册方拥有自己的行文案与行为。例如「外观」行位于 ui-theme。 ### 打开配置文件 @@ -55,7 +55,7 @@ Web 与桌面端的通用设置底部显示当前发布版本,使用构建注
    实现细节——点击展开 -外壳拥有界面框架与投影,并提供开发者工具行;功能注册方拥有其余内容与文案。 +外壳拥有界面框架与投影,并提供开发者工具行与当前版本行;功能注册方拥有其余内容与文案。 ### 账本投影 @@ -106,7 +106,7 @@ Web 与桌面端的通用设置底部显示当前发布版本,使用构建注 这些限制说明外壳自身提供什么、功能包必须提供什么;它们是当前包约束。 -- **额外的通用设置行需要相应功能插件**:外壳提供开发者工具;功能插件提供其他偏好。 +- **额外的通用设置行需要相应功能插件**:外壳提供开发者工具与当前版本;功能插件提供其他偏好。 - **Windows 顶栏徽标的气泡仍向右展开**:`DesktopUpdateBadge` 占用顶栏的 `sidebar.toggle.badge` 且请求 `side="right"`,侧栏收起时其气泡会被 Desktop 菜单文字遮挡;侧栏开关与新建会话的气泡则改在顶栏下方展开(#4688)。 diff --git a/packages/client/ui-settings-general/src/client/CurrentVersionRow.tsx b/packages/client/ui-settings-general/src/client/CurrentVersionRow.tsx index d02213a8d8..e3c8cb702f 100644 --- a/packages/client/ui-settings-general/src/client/CurrentVersionRow.tsx +++ b/packages/client/ui-settings-general/src/client/CurrentVersionRow.tsx @@ -4,7 +4,7 @@ import css from './CurrentVersionRow.module.css' /** * Render the version embedded by the client build; partial builds without metadata omit the row. - * @param props - Localized settings copy. + * @param props - runtime share and localized copy. * @returns the current release label, or nothing when build metadata is absent. */ export function CurrentVersionRow({ t }: PropsRuntime<'settings.general.item'> & PropsLocale<'settings'>) { diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index abd320ac69..427229d47c 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -77,6 +77,7 @@ export function apply(ctx: ClientContext): void { setEnabled: enabled => ctx.settingsScope.developerTools.setEnabled(enabled), }), }, DeveloperToolsRow)) + // Last row: every feature-registered preference row orders below 100. ctx.slots.inject('settings.general.item', () => ctx.slots.register({ name: 'settings.general.item', id: 'current-version', order: 100, locale: NS, }, CurrentVersionRow)) diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index 14bbb0bc62..ab2e3c5415 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -237,6 +237,7 @@ describe('ui-settings-general apply', () => { await c.unload(SELF) await c.flush() for (const [name] of SEATS) expect(ownEntries(c, name)).toHaveLength(0) + expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS)).toEqual([]) expect(c.ctx.slots.spec('settings.general.item')).toBeUndefined() }) }) diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index e73047e0de..fab06a77e7 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -226,9 +226,9 @@ it('reports a failed developer-tool write and allows retry', async () => { describe('current version', () => { it.each([ - [en, 'Current version: 1.2.3-rc.4'], - [zh, '当前版本:1.2.3-rc.4'], - ])('renders the localized release label %s', (dictionary, expected) => { + ['Current version: 1.2.3-rc.4', en], + ['当前版本:1.2.3-rc.4', zh], + ])('renders the localized release label %s', (expected, dictionary) => { vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3-rc.4') const translate: TriggerContentProps['t'] = (key, params) => { let text = (dictionary as Record)[key] ?? key From 654caa4bbd691dc86f168ed7f744eebdbfa32f08 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sun, 20 Sep 2026 18:20:24 +0800 Subject: [PATCH 35/60] feat(bundle): accept an ordered patch file list in dsh.bundle.patch (#4722) * feat(bundle): accept an ordered patch file list in dsh.bundle.patch `dsh.bundle.patch` now accepts `string | string[]`. A list names patch files applied in order as one bundle layer; each file's relative plugin paths still resolve beside that file. `bundlePatchPaths` in dsh-app-boot is the one resolver from a declaration to absolute paths, and every reader uses it: profile loading, plugin-manager reconcile and bundle rows, the client-runtime roster, and the workspace, cordis-config, and product-isolation gates. `ProfileLayer.patchPath` becomes `patchPaths`. * fix(bundle): keep declared patch files package-relative in the workspace gate path.join produced backslash entries on Windows, so the expected files list did not match package.json files. bundlePatchFiles returns the validated declaration as written; bundlePatchPaths joins it. --- apps/cli/tests/resolved-profile-boot.spec.ts | 2 +- apps/web/tests/scaffold.ts | 2 +- docs/user/develop/basic/publish.i18n.yaml | 4 +- docs/user/develop/basic/publish.md | 2 +- docs/user/develop/basic/publish.zh.md | 2 +- packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 2 +- packages/boot/app-boot/README.zh.md | 2 +- packages/boot/app-boot/src/index.ts | 2 + packages/boot/app-boot/src/profile.ts | 52 ++++++++++++++----- .../app-boot/tests/profile-resolution.spec.ts | 12 ++--- packages/boot/app-boot/tests/profile.spec.ts | 40 ++++++++++++-- packages/boot/plugin-manager/src/index.ts | 14 ++--- .../boot/plugin-manager/src/operations.ts | 4 +- .../src/assembly/bundle-roster.ts | 20 ++++--- .../assembly-bundle-roster.client.spec.ts | 13 ++++- .../tests/fixtures/production-profile.ts | 2 +- .../util/package-manifest/README.i18n.yaml | 4 +- packages/util/package-manifest/README.md | 2 +- packages/util/package-manifest/README.zh.md | 2 +- packages/util/package-manifest/src/types.ts | 4 +- scripts/check-workspace-constraints.spec.ts | 9 ++++ scripts/check-workspace-constraints.ts | 11 ++-- scripts/verify-cordis-config.ts | 15 +++--- scripts/verify-default-product-isolation.ts | 13 ++--- 25 files changed, 166 insertions(+), 73 deletions(-) diff --git a/apps/cli/tests/resolved-profile-boot.spec.ts b/apps/cli/tests/resolved-profile-boot.spec.ts index ff7609c83b..0df34251c6 100644 --- a/apps/cli/tests/resolved-profile-boot.spec.ts +++ b/apps/cli/tests/resolved-profile-boot.spec.ts @@ -125,7 +125,7 @@ describe('runProfile with an application-owned profile', () => { name: 'desktop', dir: home, patchPath: profilePatch, patches: [{ id: 'target', config: { profile: true, priority: 'profile' } }], layers: [{ - packageName: 'test-bundle', packageDir: home, patchPath: join(home, 'bundle.yml'), + packageName: 'test-bundle', packageDir: home, patchPaths: [join(home, 'bundle.yml')], patches: [{ insert: [ { id: 'target', name: 'target', config: { bundle: true, priority: 'bundle' } }, { id: 'session-telemetry-otel', name: 'telemetry' }, diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index b02ae8b91a..1a95d41d96 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -726,7 +726,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise` and combines installable bundles with its own `cordis.patch.yml`. The YAML composition enables or disables HMR. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh --profile --from-default-profile